diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 9a2bfdd0..a4ee80ba 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -160,9 +160,30 @@ Settings are organized into categories. All settings should be placed within the - **Default:** `undefined` - **`model.chatCompression.contextPercentageThreshold`** (number): - - **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. + - **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. - **Default:** `0.7` +- **`model.generationConfig`** (object): + - **Description:** Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. + - **Default:** `undefined` + - **Example:** + + ```json + { + "model": { + "generationConfig": { + "timeout": 60000, + "disableCacheControl": false, + "samplingParams": { + "temperature": 0.2, + "top_p": 0.8, + "max_tokens": 1024 + } + } + } + } + ``` + - **`model.skipNextSpeakerCheck`** (boolean): - **Description:** Skip the next speaker check. - **Default:** `false` @@ -171,6 +192,10 @@ Settings are organized into categories. All settings should be placed within the - **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. - **Default:** `false` +- **`model.skipStartupContext`** (boolean): + - **Description:** Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. + - **Default:** `false` + - **`model.enableOpenAILogging`** (boolean): - **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. - **Default:** `false` @@ -266,6 +291,21 @@ Settings are organized into categories. All settings should be placed within the - **Description:** Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. - **Default:** `true` +- **`tools.enableToolOutputTruncation`** (boolean): + - **Description:** Enable truncation of large tool outputs. + - **Default:** `true` + - **Requires restart:** Yes + +- **`tools.truncateToolOutputThreshold`** (number): + - **Description:** Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. + - **Default:** `25000` + - **Requires restart:** Yes + +- **`tools.truncateToolOutputLines`** (number): + - **Description:** Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. + - **Default:** `1000` + - **Requires restart:** Yes + #### `mcp` - **`mcp.serverCommand`** (string): @@ -551,7 +591,7 @@ Arguments passed directly when running the CLI can override other configurations - Example: `qwen --approval-mode auto-edit` - **`--allowed-tools `**: - A comma-separated list of tool names that will bypass the confirmation dialog. - - Example: `qwen --allowed-tools "ShellTool(git status)"` + - Example: `qwen --allowed-tools "Shell(git status)"` - **`--telemetry`**: - Enables [telemetry](../telemetry.md). - **`--telemetry-target`**: diff --git a/docs/core/tools-api.md b/docs/core/tools-api.md index 4d788a68..70266b88 100644 --- a/docs/core/tools-api.md +++ b/docs/core/tools-api.md @@ -21,7 +21,7 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi - **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content. - **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for: - - **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`). + - **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ListFiles`, `ReadFile`). - **Discovering Tools:** It can also discover tools dynamically: - **Command-based Discovery:** If `tools.toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances. - **MCP-based Discovery:** If `mcp.mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`). @@ -33,20 +33,24 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi The core comes with a suite of pre-defined tools, typically found in `packages/core/src/tools/`. These include: - **File System Tools:** - - `LSTool` (`ls.ts`): Lists directory contents. - - `ReadFileTool` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path. - - `WriteFileTool` (`write-file.ts`): Writes content to a file. - - `GrepTool` (`grep.ts`): Searches for patterns in files. - - `GlobTool` (`glob.ts`): Finds files matching glob patterns. - - `EditTool` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation). - - `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI). + - `ListFiles` (`ls.ts`): Lists directory contents. + - `ReadFile` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path. + - `WriteFile` (`write-file.ts`): Writes content to a file. + - `ReadManyFiles` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI). + - `Grep` (`grep.ts`): Searches for patterns in files. + - `Glob` (`glob.ts`): Finds files matching glob patterns. + - `Edit` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation). - **Execution Tools:** - - `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation). + - `Shell` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation). - **Web Tools:** - - `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL. - - `WebSearchTool` (`web-search.ts`): Performs a web search. + - `WebFetch` (`web-fetch.ts`): Fetches content from a URL. + - `WebSearch` (`web-search.ts`): Performs a web search. - **Memory Tools:** - - `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory. + - `SaveMemory` (`memoryTool.ts`): Interacts with the AI's memory. +- **Planning Tools:** + - `Task` (`task.ts`): Delegates tasks to specialized subagents. + - `TodoWrite` (`todoWrite.ts`): Creates and manages a structured task list. + - `ExitPlanMode` (`exitPlanMode.ts`): Exits plan mode and returns to normal operation. Each of these tools extends `BaseTool` and implements the required methods for its specific functionality. diff --git a/docs/features/subagents.md b/docs/features/subagents.md index 15b5e273..506d856f 100644 --- a/docs/features/subagents.md +++ b/docs/features/subagents.md @@ -106,7 +106,10 @@ Subagents are configured using Markdown files with YAML frontmatter. This format --- name: agent-name description: Brief description of when and how to use this agent -tools: tool1, tool2, tool3 # Optional +tools: + - tool1 + - tool2 + - tool3 # Optional --- System prompt content goes here. @@ -167,7 +170,11 @@ Perfect for comprehensive test creation and test-driven development. --- name: testing-expert description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices -tools: read_file, write_file, read_many_files, run_shell_command +tools: + - read_file + - write_file + - read_many_files + - run_shell_command --- You are a testing specialist focused on creating high-quality, maintainable tests. @@ -207,7 +214,11 @@ Specialized in creating clear, comprehensive documentation. --- name: documentation-writer description: Creates comprehensive documentation, README files, API docs, and user guides -tools: read_file, write_file, read_many_files, web_search +tools: + - read_file + - write_file + - read_many_files + - web_search --- You are a technical documentation specialist for ${project_name}. @@ -256,7 +267,9 @@ Focused on code quality, security, and best practices. --- name: code-reviewer description: Reviews code for best practices, security issues, performance, and maintainability -tools: read_file, read_many_files +tools: + - read_file + - read_many_files --- You are an experienced code reviewer focused on quality, security, and maintainability. @@ -298,7 +311,11 @@ Optimized for React development, hooks, and component patterns. --- name: react-specialist description: Expert in React development, hooks, component patterns, and modern React best practices -tools: read_file, write_file, read_many_files, run_shell_command +tools: + - read_file + - write_file + - read_many_files + - run_shell_command --- You are a React specialist with deep expertise in modern React development. @@ -339,7 +356,11 @@ Specialized in Python development, frameworks, and best practices. --- name: python-expert description: Expert in Python development, frameworks, testing, and Python-specific best practices -tools: read_file, write_file, read_many_files, run_shell_command +tools: + - read_file + - write_file + - read_many_files + - run_shell_command --- You are a Python expert with deep knowledge of the Python ecosystem. diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md index 7bf90d06..3c5097df 100644 --- a/docs/tools/file-system.md +++ b/docs/tools/file-system.md @@ -4,12 +4,12 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local **Note:** All file system tools operate within a `rootDirectory` (usually the current working directory where you launched the CLI) for security. Paths that you provide to these tools are generally expected to be absolute or are resolved relative to this root directory. -## 1. `list_directory` (ReadFolder) +## 1. `list_directory` (ListFiles) `list_directory` lists the names of files and subdirectories directly within a specified directory path. It can optionally ignore entries matching provided glob patterns. - **Tool name:** `list_directory` -- **Display name:** ReadFolder +- **Display name:** ListFiles - **File:** `ls.ts` - **Parameters:** - `path` (string, required): The absolute path to the directory to list. @@ -59,86 +59,80 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local - **Output (`llmContent`):** A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` or `Successfully created and wrote to new file: /path/to/new/file.txt`. - **Confirmation:** Yes. Shows a diff of changes and asks for user approval before writing. -## 4. `glob` (FindFiles) +## 4. `glob` (Glob) `glob` finds files matching specific glob patterns (e.g., `src/**/*.ts`, `*.md`), returning absolute paths sorted by modification time (newest first). - **Tool name:** `glob` -- **Display name:** FindFiles +- **Display name:** Glob - **File:** `glob.ts` - **Parameters:** - `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`). - - `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory. - - `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`. - - `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`. + - `path` (string, optional): The directory to search in. If not specified, the current working directory will be used. - **Behavior:** - Searches for files matching the glob pattern within the specified directory. - Returns a list of absolute paths, sorted with the most recently modified files first. - - Ignores common nuisance directories like `node_modules` and `.git` by default. -- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...` + - Respects .gitignore and .qwenignore patterns by default. + - Limits results to 100 files to prevent context overflow. +- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...` - **Confirmation:** No. -## 5. `search_file_content` (SearchText) +## 5. `grep_search` (Grep) -`search_file_content` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers. +`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers. -- **Tool name:** `search_file_content` -- **Display name:** SearchText -- **File:** `grep.ts` +- **Tool name:** `grep_search` +- **Display name:** Grep +- **File:** `ripGrep.ts` (with `grep.ts` as fallback) - **Parameters:** - - `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`). - - `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory. - - `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores). - - `maxResults` (number, optional): Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches. + - `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`). + - `path` (string, optional): File or directory to search in. Defaults to current working directory. + - `glob` (string, optional): Glob pattern to filter files (e.g. `"*.js"`, `"src/**/*.{ts,tsx}"`). + - `limit` (number, optional): Limit output to first N matching lines. Optional - shows all matches if not specified. - **Behavior:** - - Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search. - - Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number. - - Limits results to a maximum of 20 matches by default to prevent context overflow. When results are truncated, shows a clear warning with guidance on refining searches. + - Uses ripgrep for fast search when available; otherwise falls back to a JavaScript-based search implementation. + - Returns matching lines with file paths and line numbers. + - Case-insensitive by default. + - Respects .gitignore and .qwenignore patterns. + - Limits output to prevent context overflow. - **Output (`llmContent`):** A formatted string of matches, e.g.: ``` Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"): --- - File: src/utils.ts - L15: export function myFunction() { - L22: myFunction.call(); - --- - File: src/index.ts - L5: import { myFunction } from './utils'; + src/utils.ts:15:export function myFunction() { + src/utils.ts:22: myFunction.call(); + src/index.ts:5:import { myFunction } from './utils'; --- - WARNING: Results truncated to prevent context overflow. To see more results: - - Use a more specific pattern to reduce matches - - Add file filters with the 'include' parameter (e.g., "*.js", "src/**") - - Specify a narrower 'path' to search in a subdirectory - - Increase 'maxResults' parameter if you need more matches (current: 20) + [0 lines truncated] ... ``` - **Confirmation:** No. -### `search_file_content` examples +### `grep_search` examples Search for a pattern with default result limiting: ``` -search_file_content(pattern="function\s+myFunction", path="src") +grep_search(pattern="function\\s+myFunction", path="src") ``` Search for a pattern with custom result limiting: ``` -search_file_content(pattern="function", path="src", maxResults=50) +grep_search(pattern="function", path="src", limit=50) ``` Search for a pattern with file filtering and custom result limiting: ``` -search_file_content(pattern="function", include="*.js", maxResults=10) +grep_search(pattern="function", glob="*.js", limit=10) ``` ## 6. `edit` (Edit) -`edit` replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when `expected_replacements` is specified. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location. +`edit` replaces text within a file. By default it requires `old_string` to match a single unique location; set `replace_all` to `true` when you intentionally want to change every occurrence. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location. - **Tool name:** `edit` - **Display name:** Edit @@ -150,12 +144,12 @@ search_file_content(pattern="function", include="*.js", maxResults=10) **CRITICAL:** This string must uniquely identify the single instance to change. It should include at least 3 lines of context _before_ and _after_ the target text, matching whitespace and indentation precisely. If `old_string` is empty, the tool attempts to create a new file at `file_path` with `new_string` as content. - `new_string` (string, required): The exact literal text to replace `old_string` with. - - `expected_replacements` (number, optional): The number of occurrences to replace. Defaults to `1`. + - `replace_all` (boolean, optional): Replace all occurrences of `old_string`. Defaults to `false`. - **Behavior:** - If `old_string` is empty and `file_path` does not exist, creates a new file with `new_string` as content. - - If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence of `old_string`. - - If one occurrence is found, it replaces it with `new_string`. + - If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence unless `replace_all` is true. + - If the match is unique (or `replace_all` is true), it replaces the text with `new_string`. - **Enhanced Reliability (Multi-Stage Edit Correction):** To significantly improve the success rate of edits, especially when the model-provided `old_string` might not be perfectly precise, the tool incorporates a multi-stage edit correction mechanism. - If the initial `old_string` isn't found or matches multiple locations, the tool can leverage the Qwen model to iteratively refine `old_string` (and potentially `new_string`). - This self-correction process attempts to identify the unique segment the model intended to modify, making the `edit` operation more robust even with slightly imperfect initial context. @@ -164,10 +158,10 @@ search_file_content(pattern="function", include="*.js", maxResults=10) - `old_string` is not empty, but the `file_path` does not exist. - `old_string` is empty, but the `file_path` already exists. - `old_string` is not found in the file after attempts to correct it. - - `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match. + - `old_string` is found multiple times, `replace_all` is false, and the self-correction mechanism cannot resolve it to a single, unambiguous match. - **Output (`llmContent`):** - On success: `Successfully modified file: /path/to/file.txt (1 replacements).` or `Created new file: /path/to/new_file.txt with provided content.` - - On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit, expected 1 occurrences but found 2...`). + - On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit because the text matches multiple locations...`). - **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file. These file system tools provide a foundation for Qwen Code to understand and interact with your local project context. diff --git a/package-lock.json b/package-lock.json index 6f68eccf..296fc29b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.5", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.1.5", + "version": "0.2.2", "workspaces": [ "packages/*" ], @@ -555,7 +555,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -579,7 +578,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2120,7 +2118,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3282,7 +3279,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3721,7 +3717,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3732,7 +3727,6 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3938,7 +3932,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -4707,7 +4700,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5062,7 +5054,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -6227,6 +6220,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7260,7 +7254,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -7730,6 +7723,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7791,6 +7785,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -7800,6 +7795,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -7809,6 +7805,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -7975,6 +7972,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -7993,6 +7991,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8001,13 +8000,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9046,7 +9047,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -10950,6 +10950,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -12157,7 +12158,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -12661,7 +12663,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12672,7 +12673,6 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -12706,7 +12706,6 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -14516,7 +14515,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14690,8 +14688,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -14699,7 +14696,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -14884,7 +14880,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15154,6 +15149,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -15209,7 +15205,6 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -15323,7 +15318,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15337,7 +15331,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -16016,7 +16009,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -16032,7 +16024,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.1.5", + "version": "0.2.2", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -16147,7 +16139,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.1.5", + "version": "0.2.2", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -16277,7 +16269,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16287,7 +16278,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.1.5", + "version": "0.2.2", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -16299,7 +16290,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.1.5", + "version": "0.2.2", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 0208d435..a8b69061 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.5", + "version": "0.2.2", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index f7773554..33f9596d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.1.5", + "version": "0.2.2", "description": "Qwen Code", "repository": { "type": "git", @@ -32,7 +32,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 70dad3c3..b3d51813 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -872,6 +872,7 @@ export async function loadCliConfig( skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, skipLoopDetection: settings.model?.skipLoopDetection ?? false, + skipStartupContext: settings.model?.skipStartupContext ?? false, vlmSwitchMode, truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold, truncateToolOutputLines: settings.tools?.truncateToolOutputLines, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 65e73668..edc7709f 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -131,6 +131,7 @@ const MIGRATION_MAP: Record = { sessionTokenLimit: 'model.sessionTokenLimit', contentGenerator: 'model.generationConfig', skipLoopDetection: 'model.skipLoopDetection', + skipStartupContext: 'model.skipStartupContext', enableOpenAILogging: 'model.enableOpenAILogging', tavilyApiKey: 'advanced.tavilyApiKey', vlmSwitchMode: 'experimental.vlmSwitchMode', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index da504c29..70037dfd 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -12,6 +12,7 @@ import type { ChatCompressionSettings, } from '@qwen-code/qwen-code-core'; import { + ApprovalMode, DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, } from '@qwen-code/qwen-code-core'; @@ -549,6 +550,16 @@ const SETTINGS_SCHEMA = { description: 'Disable all loop detection checks (streaming and LLM).', showInDialog: true, }, + skipStartupContext: { + type: 'boolean', + label: 'Skip Startup Context', + category: 'Model', + requiresRestart: true, + default: false, + description: + 'Avoid sending the workspace startup context at the beginning of each session.', + showInDialog: true, + }, enableOpenAILogging: { type: 'boolean', label: 'Enable OpenAI Logging', @@ -820,14 +831,20 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.UNION, }, approvalMode: { - type: 'string', - label: 'Default Approval Mode', + type: 'enum', + label: 'Approval Mode', category: 'Tools', requiresRestart: false, - default: 'default', + default: ApprovalMode.DEFAULT, description: - 'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.', + 'Approval mode for tool usage. Controls how tools are approved before execution.', showInDialog: true, + options: [ + { value: ApprovalMode.PLAN, label: 'Plan' }, + { value: ApprovalMode.DEFAULT, label: 'Default' }, + { value: ApprovalMode.AUTO_EDIT, label: 'Auto Edit' }, + { value: ApprovalMode.YOLO, label: 'YOLO' }, + ], }, discoveryCommand: { type: 'string', diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index b74261da..5cc53fc6 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -25,6 +25,7 @@ import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; import { vi, type Mock, type MockInstance } from 'vitest'; import type { LoadedSettings } from './config/settings.js'; +import { CommandKind } from './ui/commands/types.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); @@ -799,6 +800,7 @@ describe('runNonInteractive', () => { const mockCommand = { name: 'testcommand', description: 'a test command', + kind: CommandKind.FILE, action: vi.fn().mockResolvedValue({ type: 'submit_prompt', content: [{ text: 'Prompt from command' }], @@ -838,6 +840,7 @@ describe('runNonInteractive', () => { const mockCommand = { name: 'confirm', description: 'a command that needs confirmation', + kind: CommandKind.FILE, action: vi.fn().mockResolvedValue({ type: 'confirm_shell_commands', commands: ['rm -rf /'], @@ -893,6 +896,7 @@ describe('runNonInteractive', () => { const mockCommand = { name: 'noaction', description: 'unhandled type', + kind: CommandKind.FILE, action: vi.fn().mockResolvedValue({ type: 'unhandled', }), @@ -919,6 +923,7 @@ describe('runNonInteractive', () => { const mockCommand = { name: 'testargs', description: 'a test command', + kind: CommandKind.FILE, action: mockAction, }; mockGetCommands.mockReturnValue([mockCommand]); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 166a1706..77b9d099 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -13,15 +13,56 @@ import { type Config, } from '@qwen-code/qwen-code-core'; import { CommandService } from './services/CommandService.js'; +import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; -import type { CommandContext } from './ui/commands/types.js'; +import { + CommandKind, + type CommandContext, + type SlashCommand, +} from './ui/commands/types.js'; import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; import type { LoadedSettings } from './config/settings.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js'; +/** + * Filters commands based on the allowed built-in command names. + * + * - Always includes FILE commands + * - Only includes BUILT_IN commands if their name is in the allowed set + * - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode + * + * @param commands All loaded commands + * @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed) + * @returns Filtered commands + */ +function filterCommandsForNonInteractive( + commands: readonly SlashCommand[], + allowedBuiltinCommandNames: Set, +): SlashCommand[] { + return commands.filter((cmd) => { + if (cmd.kind === CommandKind.FILE) { + return true; + } + + // Built-in commands: only include if in the allowed list + if (cmd.kind === CommandKind.BUILT_IN) { + return allowedBuiltinCommandNames.has(cmd.name); + } + + // Exclude other types (e.g., MCP_PROMPT) in non-interactive mode + return false; + }); +} + /** * Processes a slash command in a non-interactive environment. * + * @param rawQuery The raw query string (should start with '/') + * @param abortController Controller to cancel the operation + * @param config The configuration object + * @param settings The loaded settings + * @param allowedBuiltinCommandNames Optional array of built-in command names that are + * allowed. If not provided or empty, only file commands are available. * @returns A Promise that resolves to `PartListUnion` if a valid command is * found and results in a prompt, or `undefined` otherwise. * @throws {FatalInputError} if the command result is not supported in @@ -32,21 +73,35 @@ export const handleSlashCommand = async ( abortController: AbortController, config: Config, settings: LoadedSettings, + allowedBuiltinCommandNames?: string[], ): Promise => { const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/')) { return; } - // Only custom commands are supported for now. - const loaders = [new FileCommandLoader(config)]; + const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); + + // Only load BuiltinCommandLoader if there are allowed built-in commands + const loaders = + allowedBuiltinSet.size > 0 + ? [new BuiltinCommandLoader(config), new FileCommandLoader(config)] + : [new FileCommandLoader(config)]; + const commandService = await CommandService.create( loaders, abortController.signal, ); const commands = commandService.getCommands(); + const filteredCommands = filterCommandsForNonInteractive( + commands, + allowedBuiltinSet, + ); - const { commandToExecute, args } = parseSlashCommand(rawQuery, commands); + const { commandToExecute, args } = parseSlashCommand( + rawQuery, + filteredCommands, + ); if (commandToExecute) { if (commandToExecute.action) { @@ -107,3 +162,44 @@ export const handleSlashCommand = async ( return; }; + +/** + * Retrieves all available slash commands for the current configuration. + * + * @param config The configuration object + * @param settings The loaded settings + * @param abortSignal Signal to cancel the loading process + * @param allowedBuiltinCommandNames Optional array of built-in command names that are + * allowed. If not provided or empty, only file commands are available. + * @returns A Promise that resolves to an array of SlashCommand objects + */ +export const getAvailableCommands = async ( + config: Config, + settings: LoadedSettings, + abortSignal: AbortSignal, + allowedBuiltinCommandNames?: string[], +): Promise => { + try { + const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []); + + // Only load BuiltinCommandLoader if there are allowed built-in commands + const loaders = + allowedBuiltinSet.size > 0 + ? [new BuiltinCommandLoader(config), new FileCommandLoader(config)] + : [new FileCommandLoader(config)]; + + const commandService = await CommandService.create(loaders, abortSignal); + const commands = commandService.getCommands(); + const filteredCommands = filterCommandsForNonInteractive( + commands, + allowedBuiltinSet, + ); + + // Filter out hidden commands + return filteredCommands.filter((cmd) => !cmd.hidden); + } catch (error) { + // Handle errors gracefully - log and return empty array + console.error('Error loading available commands:', error); + return []; + } +}; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 059d1dc4..c3443b43 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -53,6 +53,7 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; +import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; @@ -96,6 +97,7 @@ import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; +import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -335,6 +337,12 @@ export const AppContainer = (props: AppContainerProps) => { initializationResult.themeError, ); + const { + isApprovalModeDialogOpen, + openApprovalModeDialog, + handleApprovalModeSelect, + } = useApprovalModeCommand(settings, config); + const { setAuthState, authError, @@ -470,6 +478,7 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openModelDialog, openPermissionsDialog, + openApprovalModeDialog, quit: (messages: HistoryItem[]) => { setQuittingMessages(messages); setTimeout(async () => { @@ -495,6 +504,7 @@ export const AppContainer = (props: AppContainerProps) => { setCorgiMode, dispatchExtensionStateUpdate, openPermissionsDialog, + openApprovalModeDialog, addConfirmUpdateExtensionRequest, showQuitConfirmation, openSubagentCreateDialog, @@ -551,6 +561,11 @@ export const AppContainer = (props: AppContainerProps) => { [visionSwitchResolver], ); + // onDebugMessage should log to console, not update footer debugMessage + const onDebugMessage = useCallback((message: string) => { + console.debug(message); + }, []); + const performMemoryRefresh = useCallback(async () => { historyManager.addItem( { @@ -628,7 +643,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem, config, settings, - setDebugMessage, + onDebugMessage, handleSlashCommand, shellModeActive, () => settings.merged.general?.preferredEditor as EditorType, @@ -930,10 +945,18 @@ export const AppContainer = (props: AppContainerProps) => { settings.merged.ui?.customWittyPhrases, ); + useAttentionNotifications({ + isFocused, + streamingState, + elapsedTime, + }); + // Dialog close functionality const { closeAnyOpenDialog } = useDialogClose({ isThemeDialogOpen, handleThemeSelect, + isApprovalModeDialogOpen, + handleApprovalModeSelect, isAuthDialogOpen, handleAuthSelect, selectedAuthType: settings.merged.security?.auth?.selectedType, @@ -1183,7 +1206,8 @@ export const AppContainer = (props: AppContainerProps) => { showIdeRestartPrompt || !!proQuotaRequest || isSubagentCreateDialogOpen || - isAgentsManagerDialogOpen; + isAgentsManagerDialogOpen || + isApprovalModeDialogOpen; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], @@ -1214,6 +1238,7 @@ export const AppContainer = (props: AppContainerProps) => { isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, + isApprovalModeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1308,6 +1333,7 @@ export const AppContainer = (props: AppContainerProps) => { isSettingsDialogOpen, isModelDialogOpen, isPermissionsDialogOpen, + isApprovalModeDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -1388,6 +1414,7 @@ export const AppContainer = (props: AppContainerProps) => { () => ({ handleThemeSelect, handleThemeHighlight, + handleApprovalModeSelect, handleAuthSelect, setAuthState, onAuthError, @@ -1423,6 +1450,7 @@ export const AppContainer = (props: AppContainerProps) => { [ handleThemeSelect, handleThemeHighlight, + handleApprovalModeSelect, handleAuthSelect, setAuthState, onAuthError, diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index 4104a775..9d9baa89 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -78,20 +78,17 @@ export function AuthDialog({ ); const handleAuthSelect = (authMethod: AuthType) => { - const error = validateAuthMethod(authMethod); - if (error) { - if ( - authMethod === AuthType.USE_OPENAI && - !process.env['OPENAI_API_KEY'] - ) { - setShowOpenAIKeyPrompt(true); - setErrorMessage(null); - } else { - setErrorMessage(error); - } - } else { + if (authMethod === AuthType.USE_OPENAI) { + setShowOpenAIKeyPrompt(true); setErrorMessage(null); - onSelect(authMethod, SettingScope.User); + } else { + const error = validateAuthMethod(authMethod); + if (error) { + setErrorMessage(error); + } else { + setErrorMessage(null); + onSelect(authMethod, SettingScope.User); + } } }; @@ -137,10 +134,23 @@ export function AuthDialog({ }, { isActive: true }, ); + const getDefaultOpenAIConfig = () => { + const fromSettings = settings.merged.security?.auth; + const modelSettings = settings.merged.model; + return { + apiKey: fromSettings?.apiKey || process.env['OPENAI_API_KEY'] || '', + baseUrl: fromSettings?.baseUrl || process.env['OPENAI_BASE_URL'] || '', + model: modelSettings?.name || process.env['OPENAI_MODEL'] || '', + }; + }; if (showOpenAIKeyPrompt) { + const defaults = getDefaultOpenAIConfig(); return ( diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 414c06ad..8a1daaeb 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -8,38 +8,22 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { aboutCommand } from './aboutCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import * as versionUtils from '../../utils/version.js'; import { MessageType } from '../types.js'; -import { IdeClient } from '@qwen-code/qwen-code-core'; +import * as systemInfoUtils from '../../utils/systemInfo.js'; -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - IdeClient: { - getInstance: vi.fn().mockResolvedValue({ - getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), - }), - }, - }; -}); - -vi.mock('../../utils/version.js', () => ({ - getCliVersion: vi.fn(), -})); +vi.mock('../../utils/systemInfo.js'); describe('aboutCommand', () => { let mockContext: CommandContext; - const originalPlatform = process.platform; const originalEnv = { ...process.env }; beforeEach(() => { mockContext = createMockCommandContext({ services: { config: { - getModel: vi.fn(), + getModel: vi.fn().mockReturnValue('test-model'), getIdeMode: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), }, settings: { merged: { @@ -56,21 +40,25 @@ describe('aboutCommand', () => { }, } as unknown as CommandContext); - vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version'); - vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue( - 'test-model', - ); - process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; - Object.defineProperty(process, 'platform', { - value: 'test-os', + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, }); }); afterEach(() => { vi.unstubAllEnvs(); - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }); process.env = originalEnv; vi.clearAllMocks(); }); @@ -81,30 +69,55 @@ describe('aboutCommand', () => { }); it('should call addItem with all version info', async () => { - process.env['SANDBOX'] = ''; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } await aboutCommand.action(mockContext, ''); + expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith( + mockContext, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { + expect.objectContaining({ type: MessageType.ABOUT, - cliVersion: 'test-version', - osVersion: 'test-os', - sandboxEnv: 'no sandbox', - modelVersion: 'test-model', - selectedAuthType: 'test-auth', - gcpProject: 'test-gcp-project', - ideClient: 'test-ide', - }, + systemInfo: expect.objectContaining({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }), + }), expect.any(Number), ); }); it('should show the correct sandbox environment variable', async () => { - process.env['SANDBOX'] = 'gemini-sandbox'; + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'gemini-sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } @@ -113,15 +126,32 @@ describe('aboutCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ - sandboxEnv: 'gemini-sandbox', + type: MessageType.ABOUT, + systemInfo: expect.objectContaining({ + sandboxEnv: 'gemini-sandbox', + }), }), expect.any(Number), ); }); it('should show sandbox-exec profile when applicable', async () => { - process.env['SANDBOX'] = 'sandbox-exec'; - process.env['SEATBELT_PROFILE'] = 'test-profile'; + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'sandbox-exec (test-profile)', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } @@ -130,18 +160,31 @@ describe('aboutCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ - sandboxEnv: 'sandbox-exec (test-profile)', + systemInfo: expect.objectContaining({ + sandboxEnv: 'sandbox-exec (test-profile)', + }), }), expect.any(Number), ); }); it('should not show ide client when it is not detected', async () => { - vi.mocked(IdeClient.getInstance).mockResolvedValue({ - getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined), - } as unknown as IdeClient); + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: '', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); - process.env['SANDBOX'] = ''; if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } @@ -151,13 +194,87 @@ describe('aboutCommand', () => { expect(mockContext.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ABOUT, - cliVersion: 'test-version', - osVersion: 'test-os', - sandboxEnv: 'no sandbox', - modelVersion: 'test-model', - selectedAuthType: 'test-auth', - gcpProject: 'test-gcp-project', - ideClient: '', + systemInfo: expect.objectContaining({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: '', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }), + }), + expect.any(Number), + ); + }); + + it('should show unknown npmVersion when npm command fails', async () => { + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: 'unknown', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + systemInfo: expect.objectContaining({ + npmVersion: 'unknown', + }), + }), + expect.any(Number), + ); + }); + + it('should show unknown sessionId when config is not available', async () => { + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'Unknown', + selectedAuthType: 'test-auth', + ideClient: '', + sessionId: 'unknown', + memoryUsage: '100 MB', + baseUrl: undefined, + }); + + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + await aboutCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + systemInfo: expect.objectContaining({ + sessionId: 'unknown', + }), }), expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 36bfbdff..0f35db92 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -4,53 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { getCliVersion } from '../../utils/version.js'; -import type { CommandContext, SlashCommand } from './types.js'; +import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; -import process from 'node:process'; import { MessageType, type HistoryItemAbout } from '../types.js'; -import { IdeClient } from '@qwen-code/qwen-code-core'; +import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; export const aboutCommand: SlashCommand = { name: 'about', description: 'show version info', kind: CommandKind.BUILT_IN, action: async (context) => { - const osVersion = process.platform; - let sandboxEnv = 'no sandbox'; - if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { - sandboxEnv = process.env['SANDBOX']; - } else if (process.env['SANDBOX'] === 'sandbox-exec') { - sandboxEnv = `sandbox-exec (${ - process.env['SEATBELT_PROFILE'] || 'unknown' - })`; - } - const modelVersion = context.services.config?.getModel() || 'Unknown'; - const cliVersion = await getCliVersion(); - const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; - const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || ''; - const ideClient = await getIdeClientName(context); + const systemInfo = await getExtendedSystemInfo(context); const aboutItem: Omit = { type: MessageType.ABOUT, - cliVersion, - osVersion, - sandboxEnv, - modelVersion, - selectedAuthType, - gcpProject, - ideClient, + systemInfo, }; context.ui.addItem(aboutItem, Date.now()); }, }; - -async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { - return ''; - } - const ideClient = await IdeClient.getInstance(); - return ideClient?.getDetectedIdeDisplayName() ?? ''; -} diff --git a/packages/cli/src/ui/commands/approvalModeCommand.test.ts b/packages/cli/src/ui/commands/approvalModeCommand.test.ts index c52c84fc..f915a63c 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.test.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.test.ts @@ -4,492 +4,68 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { approvalModeCommand } from './approvalModeCommand.js'; import { type CommandContext, CommandKind, - type MessageActionReturn, + type OpenDialogActionReturn, } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { ApprovalMode } from '@qwen-code/qwen-code-core'; -import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import type { LoadedSettings } from '../../config/settings.js'; describe('approvalModeCommand', () => { let mockContext: CommandContext; - let setApprovalModeMock: ReturnType; - let setSettingsValueMock: ReturnType; - const originalEnv = { ...process.env }; - const userSettingsPath = '/mock/user/settings.json'; - const projectSettingsPath = '/mock/project/settings.json'; - const userSettingsFile = { path: userSettingsPath, settings: {} }; - const projectSettingsFile = { path: projectSettingsPath, settings: {} }; - - const getModeSubCommand = (mode: ApprovalMode) => - approvalModeCommand.subCommands?.find((cmd) => cmd.name === mode); - - const getScopeSubCommand = ( - mode: ApprovalMode, - scope: '--session' | '--user' | '--project', - ) => getModeSubCommand(mode)?.subCommands?.find((cmd) => cmd.name === scope); beforeEach(() => { - setApprovalModeMock = vi.fn(); - setSettingsValueMock = vi.fn(); - mockContext = createMockCommandContext({ services: { config: { - getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), - setApprovalMode: setApprovalModeMock, + getApprovalMode: () => 'default', + setApprovalMode: () => {}, }, settings: { merged: {}, - setValue: setSettingsValueMock, - forScope: vi - .fn() - .mockImplementation((scope: SettingScope) => - scope === SettingScope.User - ? userSettingsFile - : scope === SettingScope.Workspace - ? projectSettingsFile - : { path: '', settings: {} }, - ), + setValue: () => {}, + forScope: () => ({}), } as unknown as LoadedSettings, }, - } as unknown as CommandContext); + }); }); - afterEach(() => { - process.env = { ...originalEnv }; - vi.clearAllMocks(); - }); - - it('should have the correct command properties', () => { + it('should have correct metadata', () => { expect(approvalModeCommand.name).toBe('approval-mode'); - expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN); expect(approvalModeCommand.description).toBe( 'View or change the approval mode for tool usage', ); + expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN); }); - it('should show current mode, options, and usage when no arguments provided', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( + it('should open approval mode dialog when invoked', async () => { + const result = (await approvalModeCommand.action?.( mockContext, '', - )) as MessageActionReturn; + )) as OpenDialogActionReturn; - expect(result.type).toBe('message'); - expect(result.messageType).toBe('info'); - const expectedMessage = [ - 'Current approval mode: default', - '', - 'Available approval modes:', - ' - plan: Plan mode - Analyze only, do not modify files or execute commands', - ' - default: Default mode - Require approval for file edits or shell commands', - ' - auto-edit: Auto-edit mode - Automatically approve file edits', - ' - yolo: YOLO mode - Automatically approve all tools', - '', - 'Usage: /approval-mode [--session|--user|--project]', - ].join('\n'); - expect(result.content).toBe(expectedMessage); + expect(result.type).toBe('dialog'); + expect(result.dialog).toBe('approval-mode'); }); - it('should display error when config is not available', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } + it('should open approval mode dialog with arguments (ignored)', async () => { + const result = (await approvalModeCommand.action?.( + mockContext, + 'some arguments', + )) as OpenDialogActionReturn; - const nullConfigContext = createMockCommandContext({ - services: { - config: null, - }, - } as unknown as CommandContext); - - const result = (await approvalModeCommand.action( - nullConfigContext, - '', - )) as MessageActionReturn; - - expect(result.type).toBe('message'); - expect(result.messageType).toBe('error'); - expect(result.content).toBe('Configuration not available.'); + expect(result.type).toBe('dialog'); + expect(result.dialog).toBe('approval-mode'); }); - it('should change approval mode when valid mode is provided', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - 'plan', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN); - expect(setSettingsValueMock).not.toHaveBeenCalled(); - expect(result.type).toBe('message'); - expect(result.messageType).toBe('info'); - expect(result.content).toBe('Approval mode changed to: plan'); + it('should not have subcommands', () => { + expect(approvalModeCommand.subCommands).toBeUndefined(); }); - it('should accept canonical auto-edit mode value', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - 'auto-edit', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT); - expect(setSettingsValueMock).not.toHaveBeenCalled(); - expect(result.type).toBe('message'); - expect(result.messageType).toBe('info'); - expect(result.content).toBe('Approval mode changed to: auto-edit'); - }); - - it('should accept auto-edit alias for compatibility', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - 'auto-edit', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT); - expect(setSettingsValueMock).not.toHaveBeenCalled(); - expect(result.content).toBe('Approval mode changed to: auto-edit'); - }); - - it('should display error when invalid mode is provided', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - 'invalid', - )) as MessageActionReturn; - - expect(result.type).toBe('message'); - expect(result.messageType).toBe('error'); - expect(result.content).toContain('Invalid approval mode: invalid'); - expect(result.content).toContain('Available approval modes:'); - expect(result.content).toContain( - 'Usage: /approval-mode [--session|--user|--project]', - ); - }); - - it('should display error when setApprovalMode throws an error', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const errorMessage = 'Failed to set approval mode'; - mockContext.services.config!.setApprovalMode = vi - .fn() - .mockImplementation(() => { - throw new Error(errorMessage); - }); - - const result = (await approvalModeCommand.action( - mockContext, - 'plan', - )) as MessageActionReturn; - - expect(result.type).toBe('message'); - expect(result.messageType).toBe('error'); - expect(result.content).toBe( - `Failed to change approval mode: ${errorMessage}`, - ); - }); - - it('should allow selecting auto-edit with user scope via nested subcommands', async () => { - if (!approvalModeCommand.subCommands) { - throw new Error('approvalModeCommand must have subCommands.'); - } - - const userSubCommand = getScopeSubCommand(ApprovalMode.AUTO_EDIT, '--user'); - if (!userSubCommand?.action) { - throw new Error('--user scope subcommand must have an action.'); - } - - const result = (await userSubCommand.action( - mockContext, - '', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT); - expect(setSettingsValueMock).toHaveBeenCalledWith( - SettingScope.User, - 'approvalMode', - 'auto-edit', - ); - expect(result.content).toBe( - `Approval mode changed to: auto-edit (saved to user settings at ${userSettingsPath})`, - ); - }); - - it('should allow selecting plan with project scope via nested subcommands', async () => { - if (!approvalModeCommand.subCommands) { - throw new Error('approvalModeCommand must have subCommands.'); - } - - const projectSubCommand = getScopeSubCommand( - ApprovalMode.PLAN, - '--project', - ); - if (!projectSubCommand?.action) { - throw new Error('--project scope subcommand must have an action.'); - } - - const result = (await projectSubCommand.action( - mockContext, - '', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN); - expect(setSettingsValueMock).toHaveBeenCalledWith( - SettingScope.Workspace, - 'approvalMode', - 'plan', - ); - expect(result.content).toBe( - `Approval mode changed to: plan (saved to project settings at ${projectSettingsPath})`, - ); - }); - - it('should allow selecting plan with session scope via nested subcommands', async () => { - if (!approvalModeCommand.subCommands) { - throw new Error('approvalModeCommand must have subCommands.'); - } - - const sessionSubCommand = getScopeSubCommand( - ApprovalMode.PLAN, - '--session', - ); - if (!sessionSubCommand?.action) { - throw new Error('--session scope subcommand must have an action.'); - } - - const result = (await sessionSubCommand.action( - mockContext, - '', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN); - expect(setSettingsValueMock).not.toHaveBeenCalled(); - expect(result.content).toBe('Approval mode changed to: plan'); - }); - - it('should allow providing a scope argument after selecting a mode subcommand', async () => { - if (!approvalModeCommand.subCommands) { - throw new Error('approvalModeCommand must have subCommands.'); - } - - const planSubCommand = getModeSubCommand(ApprovalMode.PLAN); - if (!planSubCommand?.action) { - throw new Error('plan subcommand must have an action.'); - } - - const result = (await planSubCommand.action( - mockContext, - '--user', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN); - expect(setSettingsValueMock).toHaveBeenCalledWith( - SettingScope.User, - 'approvalMode', - 'plan', - ); - expect(result.content).toBe( - `Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`, - ); - }); - - it('should support --user plan pattern (scope first)', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - '--user plan', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN); - expect(setSettingsValueMock).toHaveBeenCalledWith( - SettingScope.User, - 'approvalMode', - 'plan', - ); - expect(result.content).toBe( - `Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`, - ); - }); - - it('should support plan --user pattern (mode first)', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - 'plan --user', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN); - expect(setSettingsValueMock).toHaveBeenCalledWith( - SettingScope.User, - 'approvalMode', - 'plan', - ); - expect(result.content).toBe( - `Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`, - ); - }); - - it('should support --project auto-edit pattern', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - '--project auto-edit', - )) as MessageActionReturn; - - expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT); - expect(setSettingsValueMock).toHaveBeenCalledWith( - SettingScope.Workspace, - 'approvalMode', - 'auto-edit', - ); - expect(result.content).toBe( - `Approval mode changed to: auto-edit (saved to project settings at ${projectSettingsPath})`, - ); - }); - - it('should display error when only scope flag is provided', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - '--user', - )) as MessageActionReturn; - - expect(result.type).toBe('message'); - expect(result.messageType).toBe('error'); - expect(result.content).toContain('Missing approval mode'); - expect(setApprovalModeMock).not.toHaveBeenCalled(); - expect(setSettingsValueMock).not.toHaveBeenCalled(); - }); - - it('should display error when multiple scope flags are provided', async () => { - if (!approvalModeCommand.action) { - throw new Error('approvalModeCommand must have an action.'); - } - - const result = (await approvalModeCommand.action( - mockContext, - '--user --project plan', - )) as MessageActionReturn; - - expect(result.type).toBe('message'); - expect(result.messageType).toBe('error'); - expect(result.content).toContain('Multiple scope flags provided'); - expect(setApprovalModeMock).not.toHaveBeenCalled(); - expect(setSettingsValueMock).not.toHaveBeenCalled(); - }); - - it('should surface a helpful error when scope subcommands receive extra arguments', async () => { - if (!approvalModeCommand.subCommands) { - throw new Error('approvalModeCommand must have subCommands.'); - } - - const userSubCommand = getScopeSubCommand(ApprovalMode.DEFAULT, '--user'); - if (!userSubCommand?.action) { - throw new Error('--user scope subcommand must have an action.'); - } - - const result = (await userSubCommand.action( - mockContext, - 'extra', - )) as MessageActionReturn; - - expect(result.type).toBe('message'); - expect(result.messageType).toBe('error'); - expect(result.content).toBe( - 'Scope subcommands do not accept additional arguments.', - ); - expect(setApprovalModeMock).not.toHaveBeenCalled(); - expect(setSettingsValueMock).not.toHaveBeenCalled(); - }); - - it('should provide completion for approval modes', async () => { - if (!approvalModeCommand.completion) { - throw new Error('approvalModeCommand must have a completion function.'); - } - - // Test partial mode completion - const result = await approvalModeCommand.completion(mockContext, 'p'); - expect(result).toEqual(['plan']); - - const result2 = await approvalModeCommand.completion(mockContext, 'a'); - expect(result2).toEqual(['auto-edit']); - - // Test empty completion - should suggest available modes first - const result3 = await approvalModeCommand.completion(mockContext, ''); - expect(result3).toEqual(['plan', 'default', 'auto-edit', 'yolo']); - - const result4 = await approvalModeCommand.completion(mockContext, 'AUTO'); - expect(result4).toEqual(['auto-edit']); - - // Test mode first pattern: 'plan ' should suggest scope flags - const result5 = await approvalModeCommand.completion(mockContext, 'plan '); - expect(result5).toEqual(['--session', '--project', '--user']); - - const result6 = await approvalModeCommand.completion( - mockContext, - 'plan --u', - ); - expect(result6).toEqual(['--user']); - - // Test scope first pattern: '--user ' should suggest modes - const result7 = await approvalModeCommand.completion( - mockContext, - '--user ', - ); - expect(result7).toEqual(['plan', 'default', 'auto-edit', 'yolo']); - - const result8 = await approvalModeCommand.completion( - mockContext, - '--user p', - ); - expect(result8).toEqual(['plan']); - - // Test completed patterns should return empty - const result9 = await approvalModeCommand.completion( - mockContext, - 'plan --user ', - ); - expect(result9).toEqual([]); - - const result10 = await approvalModeCommand.completion( - mockContext, - '--user plan ', - ); - expect(result10).toEqual([]); + it('should not have completion function', () => { + expect(approvalModeCommand.completion).toBeUndefined(); }); }); diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index 6cef96c2..5528d86f 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -7,428 +7,19 @@ import type { SlashCommand, CommandContext, - MessageActionReturn, + OpenDialogActionReturn, } from './types.js'; import { CommandKind } from './types.js'; -import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core'; -import { SettingScope } from '../../config/settings.js'; - -const USAGE_MESSAGE = - 'Usage: /approval-mode [--session|--user|--project]'; - -const normalizeInputMode = (value: string): string => - value.trim().toLowerCase(); - -const tokenizeArgs = (args: string): string[] => { - const matches = args.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)/g); - if (!matches) { - return []; - } - - return matches.map((token) => { - if ( - (token.startsWith('"') && token.endsWith('"')) || - (token.startsWith("'") && token.endsWith("'")) - ) { - return token.slice(1, -1); - } - return token; - }); -}; - -const parseApprovalMode = (value: string | null): ApprovalMode | null => { - if (!value) { - return null; - } - - const normalized = normalizeInputMode(value).replace(/_/g, '-'); - const matchIndex = APPROVAL_MODES.findIndex( - (candidate) => candidate === normalized, - ); - - return matchIndex === -1 ? null : APPROVAL_MODES[matchIndex]; -}; - -const formatModeDescription = (mode: ApprovalMode): string => { - switch (mode) { - case ApprovalMode.PLAN: - return 'Plan mode - Analyze only, do not modify files or execute commands'; - case ApprovalMode.DEFAULT: - return 'Default mode - Require approval for file edits or shell commands'; - case ApprovalMode.AUTO_EDIT: - return 'Auto-edit mode - Automatically approve file edits'; - case ApprovalMode.YOLO: - return 'YOLO mode - Automatically approve all tools'; - default: - return `${mode} mode`; - } -}; - -const parseApprovalArgs = ( - args: string, -): { - mode: string | null; - scope: 'session' | 'user' | 'project'; - error?: string; -} => { - const trimmedArgs = args.trim(); - if (!trimmedArgs) { - return { mode: null, scope: 'session' }; - } - - const tokens = tokenizeArgs(trimmedArgs); - let mode: string | null = null; - let scope: 'session' | 'user' | 'project' = 'session'; - let scopeFlag: string | null = null; - - // Find scope flag and mode - for (const token of tokens) { - if (token === '--session' || token === '--user' || token === '--project') { - if (scopeFlag) { - return { - mode: null, - scope: 'session', - error: 'Multiple scope flags provided', - }; - } - scopeFlag = token; - scope = token.substring(2) as 'session' | 'user' | 'project'; - } else if (!mode) { - mode = token; - } else { - return { - mode: null, - scope: 'session', - error: 'Invalid arguments provided', - }; - } - } - - if (!mode) { - return { mode: null, scope: 'session', error: 'Missing approval mode' }; - } - - return { mode, scope }; -}; - -const setApprovalModeWithScope = async ( - context: CommandContext, - mode: ApprovalMode, - scope: 'session' | 'user' | 'project', -): Promise => { - const { services } = context; - const { config } = services; - - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Configuration not available.', - }; - } - - try { - // Always set the mode in the current session - config.setApprovalMode(mode); - - // If scope is not session, also persist to settings - if (scope !== 'session') { - const { settings } = context.services; - if (!settings || typeof settings.setValue !== 'function') { - return { - type: 'message', - messageType: 'error', - content: - 'Settings service is not available; unable to persist the approval mode.', - }; - } - - const settingScope = - scope === 'user' ? SettingScope.User : SettingScope.Workspace; - const scopeLabel = scope === 'user' ? 'user' : 'project'; - let settingsPath: string | undefined; - - try { - if (typeof settings.forScope === 'function') { - settingsPath = settings.forScope(settingScope)?.path; - } - } catch (_error) { - settingsPath = undefined; - } - - try { - settings.setValue(settingScope, 'approvalMode', mode); - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: `Failed to save approval mode: ${(error as Error).message}`, - }; - } - - const locationSuffix = settingsPath ? ` at ${settingsPath}` : ''; - - const scopeSuffix = ` (saved to ${scopeLabel} settings${locationSuffix})`; - - return { - type: 'message', - messageType: 'info', - content: `Approval mode changed to: ${mode}${scopeSuffix}`, - }; - } - - return { - type: 'message', - messageType: 'info', - content: `Approval mode changed to: ${mode}`, - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: `Failed to change approval mode: ${(error as Error).message}`, - }; - } -}; export const approvalModeCommand: SlashCommand = { name: 'approval-mode', description: 'View or change the approval mode for tool usage', kind: CommandKind.BUILT_IN, action: async ( - context: CommandContext, - args: string, - ): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Configuration not available.', - }; - } - - // If no arguments provided, show current mode and available options - if (!args || args.trim() === '') { - const currentMode = - typeof config.getApprovalMode === 'function' - ? config.getApprovalMode() - : null; - - const messageLines: string[] = []; - - if (currentMode) { - messageLines.push(`Current approval mode: ${currentMode}`); - messageLines.push(''); - } - - messageLines.push('Available approval modes:'); - for (const mode of APPROVAL_MODES) { - messageLines.push(` - ${mode}: ${formatModeDescription(mode)}`); - } - messageLines.push(''); - messageLines.push(USAGE_MESSAGE); - - return { - type: 'message', - messageType: 'info', - content: messageLines.join('\n'), - }; - } - - // Parse arguments flexibly - const parsed = parseApprovalArgs(args); - - if (parsed.error) { - return { - type: 'message', - messageType: 'error', - content: `${parsed.error}. ${USAGE_MESSAGE}`, - }; - } - - if (!parsed.mode) { - return { - type: 'message', - messageType: 'info', - content: USAGE_MESSAGE, - }; - } - - const requestedMode = parseApprovalMode(parsed.mode); - - if (!requestedMode) { - let message = `Invalid approval mode: ${parsed.mode}\n\n`; - message += 'Available approval modes:\n'; - for (const mode of APPROVAL_MODES) { - message += ` - ${mode}: ${formatModeDescription(mode)}\n`; - } - message += `\n${USAGE_MESSAGE}`; - return { - type: 'message', - messageType: 'error', - content: message, - }; - } - - return setApprovalModeWithScope(context, requestedMode, parsed.scope); - }, - subCommands: APPROVAL_MODES.map((mode) => ({ - name: mode, - description: formatModeDescription(mode), - kind: CommandKind.BUILT_IN, - subCommands: [ - { - name: '--session', - description: 'Apply to current session only (temporary)', - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - return { - type: 'message', - messageType: 'error', - content: 'Scope subcommands do not accept additional arguments.', - }; - } - return setApprovalModeWithScope(context, mode, 'session'); - }, - }, - { - name: '--project', - description: 'Persist for this project/workspace', - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - return { - type: 'message', - messageType: 'error', - content: 'Scope subcommands do not accept additional arguments.', - }; - } - return setApprovalModeWithScope(context, mode, 'project'); - }, - }, - { - name: '--user', - description: 'Persist for this user on this machine', - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - return { - type: 'message', - messageType: 'error', - content: 'Scope subcommands do not accept additional arguments.', - }; - } - return setApprovalModeWithScope(context, mode, 'user'); - }, - }, - ], - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - // Allow users who type `/approval-mode plan --user` via the subcommand path - const parsed = parseApprovalArgs(`${mode} ${args}`); - if (parsed.error) { - return { - type: 'message', - messageType: 'error', - content: `${parsed.error}. ${USAGE_MESSAGE}`, - }; - } - - const normalizedMode = parseApprovalMode(parsed.mode); - if (!normalizedMode) { - return { - type: 'message', - messageType: 'error', - content: `Invalid approval mode: ${parsed.mode}. ${USAGE_MESSAGE}`, - }; - } - - return setApprovalModeWithScope(context, normalizedMode, parsed.scope); - } - - return setApprovalModeWithScope(context, mode, 'session'); - }, - })), - completion: async (_context: CommandContext, partialArg: string) => { - const tokens = tokenizeArgs(partialArg); - const hasTrailingSpace = /\s$/.test(partialArg); - const currentSegment = hasTrailingSpace - ? '' - : tokens.length > 0 - ? tokens[tokens.length - 1] - : ''; - - const normalizedCurrent = normalizeInputMode(currentSegment).replace( - /_/g, - '-', - ); - - const scopeValues = ['--session', '--project', '--user']; - - const normalizeToken = (token: string) => - normalizeInputMode(token).replace(/_/g, '-'); - - const normalizedTokens = tokens.map(normalizeToken); - - if (tokens.length === 0) { - if (currentSegment.startsWith('-')) { - return scopeValues.filter((scope) => scope.startsWith(currentSegment)); - } - return APPROVAL_MODES; - } - - if (tokens.length === 1 && !hasTrailingSpace) { - const originalToken = tokens[0]; - if (originalToken.startsWith('-')) { - return scopeValues.filter((scope) => - scope.startsWith(normalizedCurrent), - ); - } - return APPROVAL_MODES.filter((mode) => - mode.startsWith(normalizedCurrent), - ); - } - - if (tokens.length === 1 && hasTrailingSpace) { - const normalizedFirst = normalizedTokens[0]; - if (scopeValues.includes(tokens[0])) { - return APPROVAL_MODES; - } - if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) { - return scopeValues; - } - return APPROVAL_MODES; - } - - if (tokens.length === 2 && !hasTrailingSpace) { - const normalizedFirst = normalizedTokens[0]; - if (scopeValues.includes(tokens[0])) { - return APPROVAL_MODES.filter((mode) => - mode.startsWith(normalizedCurrent), - ); - } - if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) { - return scopeValues.filter((scope) => - scope.startsWith(normalizedCurrent), - ); - } - return []; - } - - return []; - }, + _context: CommandContext, + _args: string, + ): Promise => ({ + type: 'dialog', + dialog: 'approval-mode', + }), }; diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 9d668055..09c28aad 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -8,41 +8,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import open from 'open'; import { bugCommand } from './bugCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { getCliVersion } from '../../utils/version.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; -import { formatMemoryUsage } from '../utils/formatters.js'; import { AuthType } from '@qwen-code/qwen-code-core'; +import * as systemInfoUtils from '../../utils/systemInfo.js'; // Mock dependencies vi.mock('open'); -vi.mock('../../utils/version.js'); -vi.mock('../utils/formatters.js'); -vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - IdeClient: { - getInstance: () => ({ - getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'), - }), - }, - }; -}); -vi.mock('node:process', () => ({ - default: { - platform: 'test-platform', - version: 'v20.0.0', - // Keep other necessary process properties if needed by other parts of the code - env: process.env, - memoryUsage: () => ({ rss: 0 }), - }, -})); +vi.mock('../../utils/systemInfo.js'); describe('bugCommand', () => { beforeEach(() => { - vi.mocked(getCliVersion).mockResolvedValue('0.1.0'); - vi.mocked(formatMemoryUsage).mockReturnValue('100 MB'); + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: '0.1.0', + osPlatform: 'test-platform', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'test', + modelVersion: 'qwen3-coder-plus', + selectedAuthType: '', + ideClient: 'VSCode', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + gitCommit: + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? GIT_COMMIT_INFO + : undefined, + }); vi.stubEnv('SANDBOX', 'qwen-test'); }); @@ -55,19 +48,7 @@ describe('bugCommand', () => { const mockContext = createMockCommandContext({ services: { config: { - getModel: () => 'qwen3-coder-plus', getBugCommand: () => undefined, - getIdeMode: () => true, - getSessionId: () => 'test-session-id', - }, - settings: { - merged: { - security: { - auth: { - selectedType: undefined, - }, - }, - }, }, }, }); @@ -75,14 +56,21 @@ describe('bugCommand', () => { if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'A test bug'); + const gitCommitLine = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? `* **Git Commit:** ${GIT_COMMIT_INFO}\n` + : ''; const expectedInfo = ` * **CLI Version:** 0.1.0 -* **Git Commit:** ${GIT_COMMIT_INFO} +${gitCommitLine}* **Model:** qwen3-coder-plus +* **Sandbox:** test +* **OS Platform:** test-platform +* **OS Arch:** x64 +* **OS Release:** 22.0.0 +* **Node.js Version:** v20.0.0 +* **NPM Version:** 10.0.0 * **Session ID:** test-session-id -* **Operating System:** test-platform v20.0.0 -* **Sandbox Environment:** test -* **Auth Type:** -* **Model Version:** qwen3-coder-plus +* **Auth Method:** * **Memory Usage:** 100 MB * **IDE Client:** VSCode `; @@ -99,19 +87,7 @@ describe('bugCommand', () => { const mockContext = createMockCommandContext({ services: { config: { - getModel: () => 'qwen3-coder-plus', getBugCommand: () => ({ urlTemplate: customTemplate }), - getIdeMode: () => true, - getSessionId: () => 'test-session-id', - }, - settings: { - merged: { - security: { - auth: { - selectedType: undefined, - }, - }, - }, }, }, }); @@ -119,14 +95,21 @@ describe('bugCommand', () => { if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'A custom bug'); + const gitCommitLine = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? `* **Git Commit:** ${GIT_COMMIT_INFO}\n` + : ''; const expectedInfo = ` * **CLI Version:** 0.1.0 -* **Git Commit:** ${GIT_COMMIT_INFO} +${gitCommitLine}* **Model:** qwen3-coder-plus +* **Sandbox:** test +* **OS Platform:** test-platform +* **OS Arch:** x64 +* **OS Release:** 22.0.0 +* **Node.js Version:** v20.0.0 +* **NPM Version:** 10.0.0 * **Session ID:** test-session-id -* **Operating System:** test-platform v20.0.0 -* **Sandbox Environment:** test -* **Auth Type:** -* **Model Version:** qwen3-coder-plus +* **Auth Method:** * **Memory Usage:** 100 MB * **IDE Client:** VSCode `; @@ -138,25 +121,30 @@ describe('bugCommand', () => { }); it('should include Base URL when auth type is OpenAI', async () => { + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: '0.1.0', + osPlatform: 'test-platform', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'test', + modelVersion: 'qwen3-coder-plus', + selectedAuthType: AuthType.USE_OPENAI, + ideClient: 'VSCode', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: 'https://api.openai.com/v1', + gitCommit: + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? GIT_COMMIT_INFO + : undefined, + }); + const mockContext = createMockCommandContext({ services: { config: { - getModel: () => 'qwen3-coder-plus', getBugCommand: () => undefined, - getIdeMode: () => true, - getSessionId: () => 'test-session-id', - getContentGeneratorConfig: () => ({ - baseUrl: 'https://api.openai.com/v1', - }), - }, - settings: { - merged: { - security: { - auth: { - selectedType: AuthType.USE_OPENAI, - }, - }, - }, }, }, }); @@ -164,15 +152,22 @@ describe('bugCommand', () => { if (!bugCommand.action) throw new Error('Action is not defined'); await bugCommand.action(mockContext, 'OpenAI bug'); + const gitCommitLine = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? `* **Git Commit:** ${GIT_COMMIT_INFO}\n` + : ''; const expectedInfo = ` * **CLI Version:** 0.1.0 -* **Git Commit:** ${GIT_COMMIT_INFO} +${gitCommitLine}* **Model:** qwen3-coder-plus +* **Sandbox:** test +* **OS Platform:** test-platform +* **OS Arch:** x64 +* **OS Release:** 22.0.0 +* **Node.js Version:** v20.0.0 +* **NPM Version:** 10.0.0 * **Session ID:** test-session-id -* **Operating System:** test-platform v20.0.0 -* **Sandbox Environment:** test -* **Auth Type:** ${AuthType.USE_OPENAI} +* **Auth Method:** ${AuthType.USE_OPENAI} * **Base URL:** https://api.openai.com/v1 -* **Model Version:** qwen3-coder-plus * **Memory Usage:** 100 MB * **IDE Client:** VSCode `; diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 2eb9b823..869024b5 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -5,17 +5,17 @@ */ import open from 'open'; -import process from 'node:process'; import { type CommandContext, type SlashCommand, CommandKind, } from './types.js'; import { MessageType } from '../types.js'; -import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; -import { formatMemoryUsage } from '../utils/formatters.js'; -import { getCliVersion } from '../../utils/version.js'; -import { IdeClient, AuthType } from '@qwen-code/qwen-code-core'; +import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; +import { + getSystemInfoFields, + getFieldValue, +} from '../../utils/systemInfoFields.js'; export const bugCommand: SlashCommand = { name: 'bug', @@ -23,50 +23,20 @@ export const bugCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); - const { config } = context.services; + const systemInfo = await getExtendedSystemInfo(context); - const osVersion = `${process.platform} ${process.version}`; - let sandboxEnv = 'no sandbox'; - if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { - sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, ''); - } else if (process.env['SANDBOX'] === 'sandbox-exec') { - sandboxEnv = `sandbox-exec (${ - process.env['SEATBELT_PROFILE'] || 'unknown' - })`; - } - const modelVersion = config?.getModel() || 'Unknown'; - const cliVersion = await getCliVersion(); - const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); - const ideClient = await getIdeClientName(context); - const selectedAuthType = - context.services.settings.merged.security?.auth?.selectedType || ''; - const baseUrl = - selectedAuthType === AuthType.USE_OPENAI - ? config?.getContentGeneratorConfig()?.baseUrl - : undefined; + const fields = getSystemInfoFields(systemInfo); - let info = ` -* **CLI Version:** ${cliVersion} -* **Git Commit:** ${GIT_COMMIT_INFO} -* **Session ID:** ${config?.getSessionId() || 'unknown'} -* **Operating System:** ${osVersion} -* **Sandbox Environment:** ${sandboxEnv} -* **Auth Type:** ${selectedAuthType}`; - if (baseUrl) { - info += `\n* **Base URL:** ${baseUrl}`; - } - info += ` -* **Model Version:** ${modelVersion} -* **Memory Usage:** ${memoryUsage} -`; - if (ideClient) { - info += `* **IDE Client:** ${ideClient}\n`; + // Generate bug report info using the same field configuration + let info = '\n'; + for (const field of fields) { + info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`; } let bugReportUrl = 'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}'; - const bugCommandSettings = config?.getBugCommand(); + const bugCommandSettings = context.services.config?.getBugCommand(); if (bugCommandSettings?.urlTemplate) { bugReportUrl = bugCommandSettings.urlTemplate; } @@ -98,11 +68,3 @@ export const bugCommand: SlashCommand = { } }, }; - -async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { - return ''; - } - const ideClient = await IdeClient.getInstance(); - return ideClient.getDetectedIdeDisplayName() ?? ''; -} diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 09e5240c..31b473c7 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -17,7 +17,7 @@ import { terminalSetup } from '../utils/terminalSetup.js'; export const terminalSetupCommand: SlashCommand = { name: 'terminal-setup', description: - 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)', kind: CommandKind.BUILT_IN, action: async (): Promise => { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index e9ee4677..e865c07e 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -129,7 +129,8 @@ export interface OpenDialogActionReturn { | 'model' | 'subagent_create' | 'subagent_list' - | 'permissions'; + | 'permissions' + | 'approval-mode'; } /** diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index 70cf47cd..fba5fb13 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -7,127 +7,46 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; +import type { ExtendedSystemInfo } from '../../utils/systemInfo.js'; +import { + getSystemInfoFields, + getFieldValue, + type SystemInfoField, +} from '../../utils/systemInfoFields.js'; -interface AboutBoxProps { - cliVersion: string; - osVersion: string; - sandboxEnv: string; - modelVersion: string; - selectedAuthType: string; - gcpProject: string; - ideClient: string; -} +type AboutBoxProps = ExtendedSystemInfo; -export const AboutBox: React.FC = ({ - cliVersion, - osVersion, - sandboxEnv, - modelVersion, - selectedAuthType, - gcpProject, - ideClient, -}) => ( - - - - About Qwen Code - - - - - - CLI Version +export const AboutBox: React.FC = (props) => { + const fields = getSystemInfoFields(props); + + return ( + + + + About Qwen Code - - {cliVersion} - + {fields.map((field: SystemInfoField) => ( + + + + {field.label} + + + + + {getFieldValue(field, props)} + + + + ))} - {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && ( - - - - Git Commit - - - - {GIT_COMMIT_INFO} - - - )} - - - - Model - - - - {modelVersion} - - - - - - Sandbox - - - - {sandboxEnv} - - - - - - OS - - - - {osVersion} - - - - - - Auth Method - - - - - {selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType} - - - - {gcpProject && ( - - - - GCP Project - - - - {gcpProject} - - - )} - {ideClient && ( - - - - IDE Client - - - - {ideClient} - - - )} - -); + ); +}; diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx new file mode 100644 index 00000000..eb6441ec --- /dev/null +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; +import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { ScopeSelector } from './shared/ScopeSelector.js'; + +interface ApprovalModeDialogProps { + /** Callback function when an approval mode is selected */ + onSelect: (mode: ApprovalMode | undefined, scope: SettingScope) => void; + + /** The settings object */ + settings: LoadedSettings; + + /** Current approval mode */ + currentMode: ApprovalMode; + + /** Available terminal height for layout calculations */ + availableTerminalHeight?: number; +} + +const formatModeDescription = (mode: ApprovalMode): string => { + switch (mode) { + case ApprovalMode.PLAN: + return 'Analyze only, do not modify files or execute commands'; + case ApprovalMode.DEFAULT: + return 'Require approval for file edits or shell commands'; + case ApprovalMode.AUTO_EDIT: + return 'Automatically approve file edits'; + case ApprovalMode.YOLO: + return 'Automatically approve all tools'; + default: + return `${mode} mode`; + } +}; + +export function ApprovalModeDialog({ + onSelect, + settings, + currentMode, + availableTerminalHeight: _availableTerminalHeight, +}: ApprovalModeDialogProps): React.JSX.Element { + // Start with User scope by default + const [selectedScope, setSelectedScope] = useState( + SettingScope.User, + ); + + // Track the currently highlighted approval mode + const [highlightedMode, setHighlightedMode] = useState( + currentMode || ApprovalMode.DEFAULT, + ); + + // Generate approval mode items with inline descriptions + const modeItems = APPROVAL_MODES.map((mode) => ({ + label: `${mode} - ${formatModeDescription(mode)}`, + value: mode, + key: mode, + })); + + // Find the index of the current mode + const initialModeIndex = modeItems.findIndex( + (item) => item.value === highlightedMode, + ); + const safeInitialModeIndex = initialModeIndex >= 0 ? initialModeIndex : 0; + + const handleModeSelect = useCallback( + (mode: ApprovalMode) => { + onSelect(mode, selectedScope); + }, + [onSelect, selectedScope], + ); + + const handleModeHighlight = (mode: ApprovalMode) => { + setHighlightedMode(mode); + }; + + const handleScopeHighlight = useCallback((scope: SettingScope) => { + setSelectedScope(scope); + }, []); + + const handleScopeSelect = useCallback( + (scope: SettingScope) => { + onSelect(highlightedMode, scope); + }, + [onSelect, highlightedMode], + ); + + const [focusSection, setFocusSection] = useState<'mode' | 'scope'>('mode'); + + useKeypress( + (key) => { + if (key.name === 'tab') { + setFocusSection((prev) => (prev === 'mode' ? 'scope' : 'mode')); + } + if (key.name === 'escape') { + onSelect(undefined, selectedScope); + } + }, + { isActive: true }, + ); + + // Generate scope message for approval mode setting + const otherScopeModifiedMessage = getScopeMessageForSetting( + 'tools.approvalMode', + selectedScope, + settings, + ); + + // Check if user scope is selected but workspace has the setting + const showWorkspacePriorityWarning = + selectedScope === SettingScope.User && + otherScopeModifiedMessage.toLowerCase().includes('workspace'); + + return ( + + + {/* Approval Mode Selection */} + + {focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '} + {otherScopeModifiedMessage} + + + + + + + {/* Scope Selection */} + + + + + + + {/* Warning when workspace setting will override user setting */} + {showWorkspacePriorityWarning && ( + <> + + ⚠ Workspace approval mode exists and takes priority. User-level + change will have no effect. + + + + )} + + + (Use Enter to select, Tab to change focus) + + + + ); +} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f174a8d0..01d95392 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -20,6 +20,7 @@ import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; import { ProQuotaDialog } from './ProQuotaDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; +import { ApprovalModeDialog } from './ApprovalModeDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; @@ -180,6 +181,22 @@ export const DialogManager = ({ onSelect={() => uiActions.closeSettingsDialog()} onRestartRequest={() => process.exit(0)} availableTerminalHeight={terminalHeight - staticExtraHeight} + config={config} + /> + + ); + } + if (uiState.isApprovalModeDialogOpen) { + const currentMode = config.getApprovalMode(); + return ( + + ); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 4eaf8ab3..7cca61ae 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -71,15 +71,24 @@ describe('', () => { it('renders AboutBox for "about" type', () => { const item: HistoryItem = { - ...baseItem, + id: 1, type: MessageType.ABOUT, - cliVersion: '1.0.0', - osVersion: 'test-os', - sandboxEnv: 'test-env', - modelVersion: 'test-model', - selectedAuthType: 'test-auth', - gcpProject: 'test-project', - ideClient: 'test-ide', + systemInfo: { + cliVersion: '1.0.0', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'test-env', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + memoryUsage: '100 MB', + baseUrl: undefined, + gitCommit: undefined, + }, }; const { lastFrame } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 1e86ffa1..bec9c23d 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -95,15 +95,7 @@ const HistoryItemDisplayComponent: React.FC = ({ )} {itemForDisplay.type === 'about' && ( - + )} {itemForDisplay.type === 'help' && commands && ( diff --git a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx index 365f557e..bc78b8c5 100644 --- a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx +++ b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx @@ -13,15 +13,21 @@ import { useKeypress } from '../hooks/useKeypress.js'; interface OpenAIKeyPromptProps { onSubmit: (apiKey: string, baseUrl: string, model: string) => void; onCancel: () => void; + defaultApiKey?: string; + defaultBaseUrl?: string; + defaultModel?: string; } export function OpenAIKeyPrompt({ onSubmit, onCancel, + defaultApiKey, + defaultBaseUrl, + defaultModel, }: OpenAIKeyPromptProps): React.JSX.Element { - const [apiKey, setApiKey] = useState(''); - const [baseUrl, setBaseUrl] = useState(''); - const [model, setModel] = useState(''); + const [apiKey, setApiKey] = useState(defaultApiKey || ''); + const [baseUrl, setBaseUrl] = useState(defaultBaseUrl || ''); + const [model, setModel] = useState(defaultModel || ''); const [currentField, setCurrentField] = useState< 'apiKey' | 'baseUrl' | 'model' >('apiKey'); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index b9e1559d..210672bb 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -9,11 +9,8 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { LoadedSettings, Settings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { - getScopeItems, - getScopeMessageForSetting, -} from '../../utils/dialogScopeUtils.js'; -import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; +import { ScopeSelector } from './shared/ScopeSelector.js'; import { getDialogSettingKeys, setPendingSettingValue, @@ -30,6 +27,7 @@ import { getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { type Config } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import chalk from 'chalk'; import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js'; @@ -43,6 +41,7 @@ interface SettingsDialogProps { onSelect: (settingName: string | undefined, scope: SettingScope) => void; onRestartRequest?: () => void; availableTerminalHeight?: number; + config?: Config; } const maxItemsToShow = 8; @@ -52,6 +51,7 @@ export function SettingsDialog({ onSelect, onRestartRequest, availableTerminalHeight, + config, }: SettingsDialogProps): React.JSX.Element { // Get vim mode context to sync vim mode changes const { vimEnabled, toggleVimEnabled } = useVimMode(); @@ -184,6 +184,21 @@ export function SettingsDialog({ }); } + // Special handling for approval mode to apply to current session + if ( + key === 'tools.approvalMode' && + settings.merged.tools?.approvalMode + ) { + try { + config?.setApprovalMode(settings.merged.tools.approvalMode); + } catch (error) { + console.error( + 'Failed to apply approval mode to current session:', + error, + ); + } + } + // Remove from modifiedSettings since it's now saved setModifiedSettings((prev) => { const updated = new Set(prev); @@ -357,12 +372,6 @@ export function SettingsDialog({ setEditCursorPos(0); }; - // Scope selector items - const scopeItems = getScopeItems().map((item) => ({ - ...item, - key: item.value, - })); - const handleScopeHighlight = (scope: SettingScope) => { setSelectedScope(scope); }; @@ -616,7 +625,11 @@ export function SettingsDialog({ prev, ), ); - } else if (defType === 'number' || defType === 'string') { + } else if ( + defType === 'number' || + defType === 'string' || + defType === 'enum' + ) { if ( typeof defaultValue === 'number' || typeof defaultValue === 'string' @@ -673,6 +686,21 @@ export function SettingsDialog({ selectedScope, ); + // Special handling for approval mode to apply to current session + if ( + currentSetting.value === 'tools.approvalMode' && + settings.merged.tools?.approvalMode + ) { + try { + config?.setApprovalMode(settings.merged.tools.approvalMode); + } catch (error) { + console.error( + 'Failed to apply approval mode to current session:', + error, + ); + } + } + // Remove from global pending changes if present setGlobalPendingChanges((prev) => { if (!prev.has(currentSetting.value)) return prev; @@ -876,19 +904,12 @@ export function SettingsDialog({ {/* Scope Selection - conditionally visible based on height constraints */} {showScopeSelection && ( - - - {focusSection === 'scope' ? '> ' : ' '}Apply To - - item.value === selectedScope, - )} + + )} diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 5e528375..b63948e1 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -28,7 +28,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -63,7 +62,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -98,7 +96,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -133,7 +130,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -168,7 +164,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -203,7 +198,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -238,7 +232,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -273,7 +266,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -308,7 +300,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ @@ -343,7 +334,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ Apply To │ │ ● User Settings │ │ Workspace Settings │ -│ System Settings │ │ │ │ (Use Enter to select, Tab to change focus) │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap index 084525d5..09787eca 100644 --- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap @@ -6,7 +6,6 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode │ > Apply To │ │ ● 1. User Settings │ │ 2. Workspace Settings │ -│ 3. System Settings │ │ │ │ (Use Enter to apply scope, Tab to select theme) │ │ │ diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e6802965..409b4c4c 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -8,7 +8,11 @@ import { createContext, useContext } from 'react'; import { type Key } from '../hooks/useKeypress.js'; import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js'; import { type FolderTrustChoice } from '../components/FolderTrustDialog.js'; -import { type AuthType, type EditorType } from '@qwen-code/qwen-code-core'; +import { + type AuthType, + type EditorType, + type ApprovalMode, +} from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; @@ -19,6 +23,10 @@ export interface UIActions { scope: SettingScope, ) => void; handleThemeHighlight: (themeName: string | undefined) => void; + handleApprovalModeSelect: ( + mode: ApprovalMode | undefined, + scope: SettingScope, + ) => void; handleAuthSelect: ( authType: AuthType | undefined, scope: SettingScope, diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e2fd4cf5..fae2db66 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -69,6 +69,7 @@ export interface UIState { isSettingsDialogOpen: boolean; isModelDialogOpen: boolean; isPermissionsDialogOpen: boolean; + isApprovalModeDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index 8f5c3710..612e4ff5 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -25,6 +25,7 @@ export const EDITOR_DISPLAY_NAMES: Record = { vscodium: 'VSCodium', windsurf: 'Windsurf', zed: 'Zed', + trae: 'Trae', }; class EditorSettingsManager { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 32876b32..d8634028 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -80,6 +80,8 @@ describe('handleAtCommand', () => { getReadManyFilesExcludes: () => [], }), getUsageStatisticsEnabled: () => false, + getTruncateToolOutputThreshold: () => 2500, + getTruncateToolOutputLines: () => 500, } as unknown as Config; const registry = new ToolRegistry(mockConfig); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index f2929d56..45411f94 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -48,6 +48,7 @@ interface SlashCommandProcessorActions { openSettingsDialog: () => void; openModelDialog: () => void; openPermissionsDialog: () => void; + openApprovalModeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; toggleCorgiMode: () => void; @@ -138,13 +139,7 @@ export const useSlashCommandProcessor = ( if (message.type === MessageType.ABOUT) { historyItemContent = { type: 'about', - cliVersion: message.cliVersion, - osVersion: message.osVersion, - sandboxEnv: message.sandboxEnv, - modelVersion: message.modelVersion, - selectedAuthType: message.selectedAuthType, - gcpProject: message.gcpProject, - ideClient: message.ideClient, + systemInfo: message.systemInfo, }; } else if (message.type === MessageType.HELP) { historyItemContent = { @@ -402,6 +397,9 @@ export const useSlashCommandProcessor = ( case 'subagent_list': actions.openAgentsManagerDialog(); return { type: 'handled' }; + case 'approval-mode': + actions.openApprovalModeDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useApprovalModeCommand.ts b/packages/cli/src/ui/hooks/useApprovalModeCommand.ts new file mode 100644 index 00000000..f328ded9 --- /dev/null +++ b/packages/cli/src/ui/hooks/useApprovalModeCommand.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import type { ApprovalMode, Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings, SettingScope } from '../../config/settings.js'; + +interface UseApprovalModeCommandReturn { + isApprovalModeDialogOpen: boolean; + openApprovalModeDialog: () => void; + handleApprovalModeSelect: ( + mode: ApprovalMode | undefined, + scope: SettingScope, + ) => void; +} + +export const useApprovalModeCommand = ( + loadedSettings: LoadedSettings, + config: Config, +): UseApprovalModeCommandReturn => { + const [isApprovalModeDialogOpen, setIsApprovalModeDialogOpen] = + useState(false); + + const openApprovalModeDialog = useCallback(() => { + setIsApprovalModeDialogOpen(true); + }, []); + + const handleApprovalModeSelect = useCallback( + (mode: ApprovalMode | undefined, scope: SettingScope) => { + try { + if (!mode) { + // User cancelled the dialog + setIsApprovalModeDialogOpen(false); + return; + } + + // Set the mode in the current session and persist to settings + loadedSettings.setValue(scope, 'tools.approvalMode', mode); + config.setApprovalMode( + loadedSettings.merged.tools?.approvalMode ?? mode, + ); + } finally { + setIsApprovalModeDialogOpen(false); + } + }, + [config, loadedSettings], + ); + + return { + isApprovalModeDialogOpen, + openApprovalModeDialog, + handleApprovalModeSelect, + }; +}; diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts new file mode 100644 index 00000000..1475aa52 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StreamingState } from '../types.js'; +import { + AttentionNotificationReason, + notifyTerminalAttention, +} from '../../utils/attentionNotification.js'; +import { + LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS, + useAttentionNotifications, +} from './useAttentionNotifications.js'; + +vi.mock('../../utils/attentionNotification.js', () => ({ + notifyTerminalAttention: vi.fn(), + AttentionNotificationReason: { + ToolApproval: 'tool_approval', + LongTaskComplete: 'long_task_complete', + }, +})); + +const mockedNotify = vi.mocked(notifyTerminalAttention); + +describe('useAttentionNotifications', () => { + beforeEach(() => { + mockedNotify.mockReset(); + }); + + const render = ( + props?: Partial[0]>, + ) => + renderHook(({ hookProps }) => useAttentionNotifications(hookProps), { + initialProps: { + hookProps: { + isFocused: true, + streamingState: StreamingState.Idle, + elapsedTime: 0, + ...props, + }, + }, + }); + + it('notifies when tool approval is required while unfocused', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.WaitingForConfirmation, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).toHaveBeenCalledWith( + AttentionNotificationReason.ToolApproval, + ); + }); + + it('notifies when focus is lost after entering approval wait state', () => { + const { rerender } = render({ + isFocused: true, + streamingState: StreamingState.WaitingForConfirmation, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.WaitingForConfirmation, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).toHaveBeenCalledTimes(1); + }); + + it('sends a notification when a long task finishes while unfocused', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Responding, + elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5, + }, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Idle, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).toHaveBeenCalledWith( + AttentionNotificationReason.LongTaskComplete, + ); + }); + + it('does not notify about long tasks when the CLI is focused', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: true, + streamingState: StreamingState.Responding, + elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2, + }, + }); + + rerender({ + hookProps: { + isFocused: true, + streamingState: StreamingState.Idle, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).not.toHaveBeenCalledWith( + AttentionNotificationReason.LongTaskComplete, + expect.anything(), + ); + }); + + it('does not treat short responses as long tasks', () => { + const { rerender } = render(); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Responding, + elapsedTime: 5, + }, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.Idle, + elapsedTime: 0, + }, + }); + + expect(mockedNotify).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts new file mode 100644 index 00000000..e632c827 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef } from 'react'; +import { StreamingState } from '../types.js'; +import { + notifyTerminalAttention, + AttentionNotificationReason, +} from '../../utils/attentionNotification.js'; + +export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20; + +interface UseAttentionNotificationsOptions { + isFocused: boolean; + streamingState: StreamingState; + elapsedTime: number; +} + +export const useAttentionNotifications = ({ + isFocused, + streamingState, + elapsedTime, +}: UseAttentionNotificationsOptions) => { + const awaitingNotificationSentRef = useRef(false); + const respondingElapsedRef = useRef(0); + + useEffect(() => { + if ( + streamingState === StreamingState.WaitingForConfirmation && + !isFocused && + !awaitingNotificationSentRef.current + ) { + notifyTerminalAttention(AttentionNotificationReason.ToolApproval); + awaitingNotificationSentRef.current = true; + } + + if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) { + awaitingNotificationSentRef.current = false; + } + }, [isFocused, streamingState]); + + useEffect(() => { + if (streamingState === StreamingState.Responding) { + respondingElapsedRef.current = elapsedTime; + return; + } + + if (streamingState === StreamingState.Idle) { + const wasLongTask = + respondingElapsedRef.current >= + LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS; + if (wasLongTask && !isFocused) { + notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete); + } + // Reset tracking for next task + respondingElapsedRef.current = 0; + return; + } + }, [streamingState, elapsedTime, isFocused]); +}; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 8c944996..06e221ac 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -6,13 +6,20 @@ import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; -import type { AuthType } from '@qwen-code/qwen-code-core'; +import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; export interface DialogCloseOptions { // Theme dialog isThemeDialogOpen: boolean; handleThemeSelect: (theme: string | undefined, scope: SettingScope) => void; + // Approval mode dialog + isApprovalModeDialogOpen: boolean; + handleApprovalModeSelect: ( + mode: ApprovalMode | undefined, + scope: SettingScope, + ) => void; + // Auth dialog isAuthDialogOpen: boolean; handleAuthSelect: ( @@ -57,6 +64,12 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.isApprovalModeDialogOpen) { + // Mimic ESC behavior: onSelect(undefined, selectedScope) - keeps current mode + options.handleApprovalModeSelect(undefined, SettingScope.User); + return true; + } + if (options.isEditorDialogOpen) { // Mimic ESC behavior: call onExit() directly options.exitEditorDialog(); diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 1d2fa782..bc9a6317 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -120,13 +120,22 @@ export type HistoryItemWarning = HistoryItemBase & { export type HistoryItemAbout = HistoryItemBase & { type: 'about'; - cliVersion: string; - osVersion: string; - sandboxEnv: string; - modelVersion: string; - selectedAuthType: string; - gcpProject: string; - ideClient: string; + systemInfo: { + cliVersion: string; + osPlatform: string; + osArch: string; + osRelease: string; + nodeVersion: string; + npmVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + ideClient: string; + sessionId: string; + memoryUsage: string; + baseUrl?: string; + gitCommit?: string; + }; }; export type HistoryItemHelp = HistoryItemBase & { @@ -288,13 +297,22 @@ export type Message = | { type: MessageType.ABOUT; timestamp: Date; - cliVersion: string; - osVersion: string; - sandboxEnv: string; - modelVersion: string; - selectedAuthType: string; - gcpProject: string; - ideClient: string; + systemInfo: { + cliVersion: string; + osPlatform: string; + osArch: string; + osRelease: string; + nodeVersion: string; + npmVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + ideClient: string; + sessionId: string; + memoryUsage: string; + baseUrl?: string; + gitCommit?: string; + }; content?: string; // Optional content, not really used for ABOUT } | { diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index a7b00d86..af5367f7 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -48,7 +48,7 @@ export interface TerminalSetupResult { requiresRestart?: boolean; } -type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf'; +type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'trae'; // Terminal detection async function detectTerminal(): Promise { @@ -68,6 +68,11 @@ async function detectTerminal(): Promise { ) { return 'windsurf'; } + + if (process.env['TERM_PRODUCT']?.toLowerCase().includes('trae')) { + return 'trae'; + } + // Check VS Code last since forks may also set VSCODE env vars if (termProgram === 'vscode' || process.env['VSCODE_GIT_IPC_HANDLE']) { return 'vscode'; @@ -86,6 +91,8 @@ async function detectTerminal(): Promise { return 'cursor'; if (parentName.includes('code') || parentName.includes('Code')) return 'vscode'; + if (parentName.includes('trae') || parentName.includes('Trae')) + return 'trae'; } catch (error) { // Continue detection even if process check fails console.debug('Parent process detection failed:', error); @@ -287,6 +294,10 @@ async function configureWindsurf(): Promise { return configureVSCodeStyle('Windsurf', 'Windsurf'); } +async function configureTrae(): Promise { + return configureVSCodeStyle('Trae', 'Trae'); +} + /** * Main terminal setup function that detects and configures the current terminal. * @@ -333,6 +344,8 @@ export async function terminalSetup(): Promise { return configureCursor(); case 'windsurf': return configureWindsurf(); + case 'trae': + return configureTrae(); default: return { success: false, diff --git a/packages/cli/src/utils/attentionNotification.test.ts b/packages/cli/src/utils/attentionNotification.test.ts new file mode 100644 index 00000000..9ebb785c --- /dev/null +++ b/packages/cli/src/utils/attentionNotification.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + notifyTerminalAttention, + AttentionNotificationReason, +} from './attentionNotification.js'; + +describe('notifyTerminalAttention', () => { + let stream: { write: ReturnType; isTTY: boolean }; + + beforeEach(() => { + stream = { write: vi.fn().mockReturnValue(true), isTTY: true }; + }); + + it('emits terminal bell character', () => { + const result = notifyTerminalAttention( + AttentionNotificationReason.ToolApproval, + { + stream, + }, + ); + + expect(result).toBe(true); + expect(stream.write).toHaveBeenCalledWith('\u0007'); + }); + + it('returns false when not running inside a tty', () => { + stream.isTTY = false; + + const result = notifyTerminalAttention( + AttentionNotificationReason.ToolApproval, + { stream }, + ); + + expect(result).toBe(false); + expect(stream.write).not.toHaveBeenCalled(); + }); + + it('returns false when stream write fails', () => { + stream.write = vi.fn().mockImplementation(() => { + throw new Error('Write failed'); + }); + + const result = notifyTerminalAttention( + AttentionNotificationReason.ToolApproval, + { stream }, + ); + + expect(result).toBe(false); + }); + + it('works with different notification reasons', () => { + const reasons = [ + AttentionNotificationReason.ToolApproval, + AttentionNotificationReason.LongTaskComplete, + ]; + + reasons.forEach((reason) => { + stream.write.mockClear(); + + const result = notifyTerminalAttention(reason, { stream }); + + expect(result).toBe(true); + expect(stream.write).toHaveBeenCalledWith('\u0007'); + }); + }); +}); diff --git a/packages/cli/src/utils/attentionNotification.ts b/packages/cli/src/utils/attentionNotification.ts new file mode 100644 index 00000000..26dc2a25 --- /dev/null +++ b/packages/cli/src/utils/attentionNotification.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; + +export enum AttentionNotificationReason { + ToolApproval = 'tool_approval', + LongTaskComplete = 'long_task_complete', +} + +export interface TerminalNotificationOptions { + stream?: Pick; +} + +const TERMINAL_BELL = '\u0007'; + +/** + * Grabs the user's attention by emitting the terminal bell character. + * This causes the terminal to flash or play a sound, alerting the user + * to check the CLI for important events. + * + * @returns true when the bell was successfully written to the terminal. + */ +export function notifyTerminalAttention( + _reason: AttentionNotificationReason, + options: TerminalNotificationOptions = {}, +): boolean { + const stream = options.stream ?? process.stdout; + if (!stream?.write || stream.isTTY === false) { + return false; + } + + try { + stream.write(TERMINAL_BELL); + return true; + } catch (error) { + console.warn('Failed to send terminal bell:', error); + return false; + } +} diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts index fd4cbbd4..027928ab 100644 --- a/packages/cli/src/utils/dialogScopeUtils.ts +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -14,7 +14,11 @@ import { settingExistsInScope } from './settingsUtils.js'; export const SCOPE_LABELS = { [SettingScope.User]: 'User Settings', [SettingScope.Workspace]: 'Workspace Settings', - [SettingScope.System]: 'System Settings', + + // TODO: migrate system settings to user settings + // we don't want to save settings to system scope, it is a troublemaker + // comment it out for now. + // [SettingScope.System]: 'System Settings', } as const; /** @@ -27,7 +31,7 @@ export function getScopeItems() { label: SCOPE_LABELS[SettingScope.Workspace], value: SettingScope.Workspace, }, - { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, + // { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, ]; } diff --git a/packages/cli/src/utils/systemInfo.test.ts b/packages/cli/src/utils/systemInfo.test.ts new file mode 100644 index 00000000..4849f1b1 --- /dev/null +++ b/packages/cli/src/utils/systemInfo.test.ts @@ -0,0 +1,331 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { + getSystemInfo, + getExtendedSystemInfo, + getNpmVersion, + getSandboxEnv, + getIdeClientName, +} from './systemInfo.js'; +import type { CommandContext } from '../ui/commands/types.js'; +import { createMockCommandContext } from '../test-utils/mockCommandContext.js'; +import * as child_process from 'node:child_process'; +import os from 'node:os'; +import { IdeClient } from '@qwen-code/qwen-code-core'; +import * as versionUtils from './version.js'; +import type { ExecSyncOptions } from 'node:child_process'; + +vi.mock('node:child_process'); + +vi.mock('node:os', () => ({ + default: { + release: vi.fn(), + }, +})); + +vi.mock('./version.js', () => ({ + getCliVersion: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + IdeClient: { + getInstance: vi.fn(), + }, + }; +}); + +describe('systemInfo', () => { + let mockContext: CommandContext; + const originalPlatform = process.platform; + const originalArch = process.arch; + const originalVersion = process.version; + const originalEnv = { ...process.env }; + + beforeEach(() => { + mockContext = createMockCommandContext({ + services: { + config: { + getModel: vi.fn().mockReturnValue('test-model'), + getIdeMode: vi.fn().mockReturnValue(true), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + baseUrl: 'https://api.openai.com', + }), + }, + settings: { + merged: { + security: { + auth: { + selectedType: 'test-auth', + }, + }, + }, + }, + }, + } as unknown as CommandContext); + + vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version'); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + vi.mocked(os.release).mockReturnValue('22.0.0'); + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; + Object.defineProperty(process, 'platform', { + value: 'test-os', + }); + Object.defineProperty(process, 'arch', { + value: 'x64', + }); + Object.defineProperty(process, 'version', { + value: 'v20.0.0', + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { + value: originalArch, + }); + Object.defineProperty(process, 'version', { + value: originalVersion, + }); + process.env = originalEnv; + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + describe('getNpmVersion', () => { + it('should return npm version when available', async () => { + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + const version = await getNpmVersion(); + expect(version).toBe('10.0.0'); + }); + + it('should return unknown when npm command fails', async () => { + vi.mocked(child_process.execSync).mockImplementation(() => { + throw new Error('npm not found'); + }); + const version = await getNpmVersion(); + expect(version).toBe('unknown'); + }); + }); + + describe('getSandboxEnv', () => { + it('should return "no sandbox" when SANDBOX is not set', () => { + delete process.env['SANDBOX']; + expect(getSandboxEnv()).toBe('no sandbox'); + }); + + it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => { + process.env['SANDBOX'] = 'sandbox-exec'; + process.env['SEATBELT_PROFILE'] = 'test-profile'; + expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)'); + }); + + it('should return sandbox name without prefix when stripPrefix is true', () => { + process.env['SANDBOX'] = 'qwen-code-test-sandbox'; + expect(getSandboxEnv(true)).toBe('test-sandbox'); + }); + + it('should return sandbox name with prefix when stripPrefix is false', () => { + process.env['SANDBOX'] = 'qwen-code-test-sandbox'; + expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox'); + }); + + it('should handle qwen- prefix removal', () => { + process.env['SANDBOX'] = 'qwen-custom-sandbox'; + expect(getSandboxEnv(true)).toBe('custom-sandbox'); + }); + }); + + describe('getIdeClientName', () => { + it('should return IDE client name when IDE mode is enabled', async () => { + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), + } as unknown as IdeClient); + + const ideClient = await getIdeClientName(mockContext); + expect(ideClient).toBe('test-ide'); + }); + + it('should return empty string when IDE mode is disabled', async () => { + vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false); + + const ideClient = await getIdeClientName(mockContext); + expect(ideClient).toBe(''); + }); + + it('should return empty string when IDE client detection fails', async () => { + vi.mocked(IdeClient.getInstance).mockRejectedValue( + new Error('IDE client error'), + ); + + const ideClient = await getIdeClientName(mockContext); + expect(ideClient).toBe(''); + }); + }); + + describe('getSystemInfo', () => { + it('should collect all system information', async () => { + // Ensure SANDBOX is not set for this test + delete process.env['SANDBOX']; + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const systemInfo = await getSystemInfo(mockContext); + + expect(systemInfo).toEqual({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'test-ide', + sessionId: 'test-session-id', + }); + }); + + it('should handle missing config gracefully', async () => { + mockContext.services.config = null; + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue(''), + } as unknown as IdeClient); + + const systemInfo = await getSystemInfo(mockContext); + + expect(systemInfo.modelVersion).toBe('Unknown'); + expect(systemInfo.sessionId).toBe('unknown'); + }); + }); + + describe('getExtendedSystemInfo', () => { + it('should include memory usage and base URL', async () => { + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const { AuthType } = await import('@qwen-code/qwen-code-core'); + // Update the mock context to use OpenAI auth + mockContext.services.settings.merged.security!.auth!.selectedType = + AuthType.USE_OPENAI; + + const extendedInfo = await getExtendedSystemInfo(mockContext); + + expect(extendedInfo.memoryUsage).toBeDefined(); + expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/); + expect(extendedInfo.baseUrl).toBe('https://api.openai.com'); + }); + + it('should use sandbox env without prefix for bug reports', async () => { + process.env['SANDBOX'] = 'qwen-code-test-sandbox'; + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue(''), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const extendedInfo = await getExtendedSystemInfo(mockContext); + + expect(extendedInfo.sandboxEnv).toBe('test-sandbox'); + }); + + it('should not include base URL for non-OpenAI auth', async () => { + vi.mocked(IdeClient.getInstance).mockResolvedValue({ + getDetectedIdeDisplayName: vi.fn().mockReturnValue(''), + } as unknown as IdeClient); + vi.mocked(child_process.execSync).mockImplementation( + (command: string, options?: ExecSyncOptions) => { + if ( + options && + typeof options === 'object' && + 'encoding' in options && + options.encoding === 'utf-8' + ) { + return '10.0.0'; + } + return Buffer.from('10.0.0', 'utf-8'); + }, + ); + + const extendedInfo = await getExtendedSystemInfo(mockContext); + + expect(extendedInfo.baseUrl).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts new file mode 100644 index 00000000..84927a95 --- /dev/null +++ b/packages/cli/src/utils/systemInfo.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; +import os from 'node:os'; +import { execSync } from 'node:child_process'; +import type { CommandContext } from '../ui/commands/types.js'; +import { getCliVersion } from './version.js'; +import { IdeClient, AuthType } from '@qwen-code/qwen-code-core'; +import { formatMemoryUsage } from '../ui/utils/formatters.js'; +import { GIT_COMMIT_INFO } from '../generated/git-commit.js'; + +/** + * System information interface containing all system-related details + * that can be collected for debugging and reporting purposes. + */ +export interface SystemInfo { + cliVersion: string; + osPlatform: string; + osArch: string; + osRelease: string; + nodeVersion: string; + npmVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + ideClient: string; + sessionId: string; +} + +/** + * Additional system information for bug reports + */ +export interface ExtendedSystemInfo extends SystemInfo { + memoryUsage: string; + baseUrl?: string; + gitCommit?: string; +} + +/** + * Gets the NPM version, handling cases where npm might not be available. + * Returns 'unknown' if npm command fails or is not found. + */ +export async function getNpmVersion(): Promise { + try { + return execSync('npm --version', { encoding: 'utf-8' }).trim(); + } catch { + return 'unknown'; + } +} + +/** + * Gets the IDE client name if IDE mode is enabled. + * Returns empty string if IDE mode is disabled or IDE client is not detected. + */ +export async function getIdeClientName( + context: CommandContext, +): Promise { + if (!context.services.config?.getIdeMode()) { + return ''; + } + try { + const ideClient = await IdeClient.getInstance(); + return ideClient?.getDetectedIdeDisplayName() ?? ''; + } catch { + return ''; + } +} + +/** + * Gets the sandbox environment information. + * Handles different sandbox types including sandbox-exec and custom sandbox environments. + * For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names. + * + * @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports) + */ +export function getSandboxEnv(stripPrefix = false): string { + const sandbox = process.env['SANDBOX']; + + if (!sandbox || sandbox === 'sandbox-exec') { + if (sandbox === 'sandbox-exec') { + const profile = process.env['SEATBELT_PROFILE'] || 'unknown'; + return `sandbox-exec (${profile})`; + } + return 'no sandbox'; + } + + // For bug reports, remove qwen- prefix + if (stripPrefix) { + return sandbox.replace(/^qwen-(?:code-)?/, ''); + } + + return sandbox; +} + +/** + * Collects comprehensive system information for debugging and reporting. + * This function gathers all system-related details including OS, versions, + * sandbox environment, authentication, and session information. + * + * @param context - Command context containing config and settings + * @returns Promise resolving to SystemInfo object with all collected information + */ +export async function getSystemInfo( + context: CommandContext, +): Promise { + const osPlatform = process.platform; + const osArch = process.arch; + const osRelease = os.release(); + const nodeVersion = process.version; + const npmVersion = await getNpmVersion(); + const sandboxEnv = getSandboxEnv(); + const modelVersion = context.services.config?.getModel() || 'Unknown'; + const cliVersion = await getCliVersion(); + const selectedAuthType = + context.services.settings.merged.security?.auth?.selectedType || ''; + const ideClient = await getIdeClientName(context); + const sessionId = context.services.config?.getSessionId() || 'unknown'; + + return { + cliVersion, + osPlatform, + osArch, + osRelease, + nodeVersion, + npmVersion, + sandboxEnv, + modelVersion, + selectedAuthType, + ideClient, + sessionId, + }; +} + +/** + * Collects extended system information for bug reports. + * Includes all standard system info plus memory usage and optional base URL. + * + * @param context - Command context containing config and settings + * @returns Promise resolving to ExtendedSystemInfo object + */ +export async function getExtendedSystemInfo( + context: CommandContext, +): Promise { + const baseInfo = await getSystemInfo(context); + const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); + + // For bug reports, use sandbox name without prefix + const sandboxEnv = getSandboxEnv(true); + + // Get base URL if using OpenAI auth + const baseUrl = + baseInfo.selectedAuthType === AuthType.USE_OPENAI + ? context.services.config?.getContentGeneratorConfig()?.baseUrl + : undefined; + + // Get git commit info + const gitCommit = + GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) + ? GIT_COMMIT_INFO + : undefined; + + return { + ...baseInfo, + sandboxEnv, + memoryUsage, + baseUrl, + gitCommit, + }; +} diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts new file mode 100644 index 00000000..d4b959fb --- /dev/null +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExtendedSystemInfo } from './systemInfo.js'; + +/** + * Field configuration for system information display + */ +export interface SystemInfoField { + label: string; + key: keyof ExtendedSystemInfo; +} + +/** + * Unified field configuration for system information display. + * This ensures consistent labeling between /about and /bug commands. + */ +export function getSystemInfoFields( + info: ExtendedSystemInfo, +): SystemInfoField[] { + const allFields: SystemInfoField[] = [ + { + label: 'CLI Version', + key: 'cliVersion', + }, + { + label: 'Git Commit', + key: 'gitCommit', + }, + { + label: 'Model', + key: 'modelVersion', + }, + { + label: 'Sandbox', + key: 'sandboxEnv', + }, + { + label: 'OS Platform', + key: 'osPlatform', + }, + { + label: 'OS Arch', + key: 'osArch', + }, + { + label: 'OS Release', + key: 'osRelease', + }, + { + label: 'Node.js Version', + key: 'nodeVersion', + }, + { + label: 'NPM Version', + key: 'npmVersion', + }, + { + label: 'Session ID', + key: 'sessionId', + }, + { + label: 'Auth Method', + key: 'selectedAuthType', + }, + { + label: 'Base URL', + key: 'baseUrl', + }, + { + label: 'Memory Usage', + key: 'memoryUsage', + }, + { + label: 'IDE Client', + key: 'ideClient', + }, + ]; + + // Filter out optional fields that are not present + return allFields.filter((field) => { + const value = info[field.key]; + // Optional fields: only show if they exist and are non-empty + if ( + field.key === 'baseUrl' || + field.key === 'gitCommit' || + field.key === 'ideClient' + ) { + return Boolean(value); + } + return true; + }); +} + +/** + * Get the value for a field from system info + */ +export function getFieldValue( + field: SystemInfoField, + info: ExtendedSystemInfo, +): string { + const value = info[field.key]; + + if (value === undefined || value === null) { + return ''; + } + + // Special formatting for selectedAuthType + if (field.key === 'selectedAuthType') { + return String(value).startsWith('oauth') ? 'OAuth' : String(value); + } + + return String(value); +} diff --git a/packages/cli/src/zed-integration/acp.ts b/packages/cli/src/zed-integration/acp.ts index 74f97cc6..a260c61e 100644 --- a/packages/cli/src/zed-integration/acp.ts +++ b/packages/cli/src/zed-integration/acp.ts @@ -7,7 +7,6 @@ /* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ import { z } from 'zod'; -import { EOL } from 'node:os'; import * as schema from './schema.js'; export * from './schema.js'; @@ -173,7 +172,7 @@ class Connection { const decoder = new TextDecoder(); for await (const chunk of output) { content += decoder.decode(chunk, { stream: true }); - const lines = content.split(EOL); + const lines = content.split('\n'); content = lines.pop() || ''; for (const line of lines) { diff --git a/packages/cli/src/zed-integration/schema.ts b/packages/cli/src/zed-integration/schema.ts index b35cc47d..e5f72b50 100644 --- a/packages/cli/src/zed-integration/schema.ts +++ b/packages/cli/src/zed-integration/schema.ts @@ -128,6 +128,14 @@ export type AgentRequest = z.infer; export type AgentNotification = z.infer; +export type AvailableCommandInput = z.infer; + +export type AvailableCommand = z.infer; + +export type AvailableCommandsUpdate = z.infer< + typeof availableCommandsUpdateSchema +>; + export const writeTextFileRequestSchema = z.object({ content: z.string(), path: z.string(), @@ -386,6 +394,21 @@ export const promptRequestSchema = z.object({ sessionId: z.string(), }); +export const availableCommandInputSchema = z.object({ + hint: z.string(), +}); + +export const availableCommandSchema = z.object({ + description: z.string(), + input: availableCommandInputSchema.nullable().optional(), + name: z.string(), +}); + +export const availableCommandsUpdateSchema = z.object({ + availableCommands: z.array(availableCommandSchema), + sessionUpdate: z.literal('available_commands_update'), +}); + export const sessionUpdateSchema = z.union([ z.object({ content: contentBlockSchema, @@ -423,6 +446,7 @@ export const sessionUpdateSchema = z.union([ entries: z.array(planEntrySchema), sessionUpdate: z.literal('plan'), }), + availableCommandsUpdateSchema, ]); export const agentResponseSchema = z.union([ diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 49e73991..d83395f2 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -12,6 +12,12 @@ import type { GeminiChat, ToolCallConfirmationDetails, ToolResult, + SubAgentEventEmitter, + SubAgentToolCallEvent, + SubAgentToolResultEvent, + SubAgentApprovalRequestEvent, + AnyDeclarativeTool, + AnyToolInvocation, } from '@qwen-code/qwen-code-core'; import { AuthType, @@ -25,9 +31,15 @@ import { MCPServerConfig, ToolConfirmationOutcome, logToolCall, + logUserPrompt, getErrorStatus, isWithinRoot, isNodeError, + SubAgentEventType, + TaskTool, + Kind, + TodoWriteTool, + UserPromptEvent, } from '@qwen-code/qwen-code-core'; import * as acp from './acp.js'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -43,6 +55,26 @@ import { ExtensionStorage, type Extension } from '../config/extension.js'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js'; +import { + handleSlashCommand, + getAvailableCommands, +} from '../nonInteractiveCliCommands.js'; +import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js'; +import { isSlashCommand } from '../ui/utils/commandUtils.js'; + +/** + * Built-in commands that are allowed in ACP integration mode. + * Only these commands will be available when using handleSlashCommand + * or getAvailableCommands in ACP integration. + * + * Currently, only "init" is supported because `handleSlashCommand` in + * nonInteractiveCliCommands.ts only supports handling results where + * result.type is "submit_prompt". Other result types are either coupled + * to the UI or cannot send notifications to the client via ACP. + * + * If you have a good idea to add support for more commands, PRs are welcome! + */ +const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init']; /** * Resolves the model to use based on the current configuration. @@ -141,7 +173,7 @@ class GeminiAgent { cwd, mcpServers, }: acp.NewSessionRequest): Promise { - const sessionId = randomUUID(); + const sessionId = this.config.getSessionId() || randomUUID(); const config = await this.newSessionConfig(sessionId, cwd, mcpServers); let isAuthenticated = false; @@ -172,9 +204,20 @@ class GeminiAgent { const geminiClient = config.getGeminiClient(); const chat = await geminiClient.startChat(); - const session = new Session(sessionId, chat, config, this.client); + const session = new Session( + sessionId, + chat, + config, + this.client, + this.settings, + ); this.sessions.set(sessionId, session); + // Send available commands update as the first session update + setTimeout(async () => { + await session.sendAvailableCommandsUpdate(); + }, 0); + return { sessionId, }; @@ -232,12 +275,14 @@ class GeminiAgent { class Session { private pendingPrompt: AbortController | null = null; + private turn: number = 0; constructor( private readonly id: string, private readonly chat: GeminiChat, private readonly config: Config, private readonly client: acp.Client, + private readonly settings: LoadedSettings, ) {} async cancelPendingPrompt(): Promise { @@ -254,10 +299,57 @@ class Session { const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; - const promptId = Math.random().toString(16).slice(2); - const chat = this.chat; + // Increment turn counter for each user prompt + this.turn += 1; - const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + const chat = this.chat; + const promptId = this.config.getSessionId() + '########' + this.turn; + + // Extract text from all text blocks to construct the full prompt text for logging + const promptText = params.prompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '); + + // Log user prompt + logUserPrompt( + this.config, + new UserPromptEvent( + promptText.length, + promptId, + this.config.getContentGeneratorConfig()?.authType, + promptText, + ), + ); + + // Check if the input contains a slash command + // Extract text from the first text block if present + const firstTextBlock = params.prompt.find((block) => block.type === 'text'); + const inputText = firstTextBlock?.text || ''; + + let parts: Part[]; + + if (isSlashCommand(inputText)) { + // Handle slash command - allow specific built-in commands for ACP integration + const slashCommandResult = await handleSlashCommand( + inputText, + pendingSend, + this.config, + this.settings, + ALLOWED_BUILTIN_COMMANDS_FOR_ACP, + ); + + if (slashCommandResult) { + // Use the result from the slash command + parts = slashCommandResult as Part[]; + } else { + // Slash command didn't return a prompt, continue with normal processing + parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + } + } else { + // Normal processing for non-slash commands + parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + } let nextMessage: Content | null = { role: 'user', parts }; @@ -351,6 +443,37 @@ class Session { await this.client.sessionUpdate(params); } + async sendAvailableCommandsUpdate(): Promise { + const abortController = new AbortController(); + try { + const slashCommands = await getAvailableCommands( + this.config, + this.settings, + abortController.signal, + ALLOWED_BUILTIN_COMMANDS_FOR_ACP, + ); + + // Convert SlashCommand[] to AvailableCommand[] format for ACP protocol + const availableCommands: AvailableCommand[] = slashCommands.map( + (cmd) => ({ + name: cmd.name, + description: cmd.description, + input: null, + }), + ); + + const update: AvailableCommandsUpdate = { + sessionUpdate: 'available_commands_update', + availableCommands, + }; + + await this.sendUpdate(update); + } catch (error) { + // Log error but don't fail session creation + console.error('Error sending available commands update:', error); + } + } + private async runTool( abortSignal: AbortSignal, promptId: string, @@ -403,9 +526,34 @@ class Session { ); } + // Detect TodoWriteTool early - route to plan updates instead of tool_call events + const isTodoWriteTool = + fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name; + + // Declare subAgentToolEventListeners outside try block for cleanup in catch + let subAgentToolEventListeners: Array<() => void> = []; + try { const invocation = tool.build(args); + // Detect TaskTool and set up sub-agent tool tracking + const isTaskTool = tool.name === TaskTool.Name; + + if (isTaskTool && 'eventEmitter' in invocation) { + // Access eventEmitter from TaskTool invocation + const taskEventEmitter = ( + invocation as { + eventEmitter: SubAgentEventEmitter; + } + ).eventEmitter; + + // Set up sub-agent tool tracking + subAgentToolEventListeners = this.setupSubAgentToolTracking( + taskEventEmitter, + abortSignal, + ); + } + const confirmationDetails = await invocation.shouldConfirmExecute(abortSignal); @@ -460,7 +608,8 @@ class Session { throw new Error(`Unexpected: ${resultOutcome}`); } } - } else { + } else if (!isTodoWriteTool) { + // Skip tool_call event for TodoWriteTool await this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: callId, @@ -473,14 +622,61 @@ class Session { } const toolResult: ToolResult = await invocation.execute(abortSignal); - const content = toToolCallContent(toolResult); - await this.sendUpdate({ - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status: 'completed', - content: content ? [content] : [], - }); + // Clean up event listeners + subAgentToolEventListeners.forEach((cleanup) => cleanup()); + + // Handle TodoWriteTool: extract todos and send plan update + if (isTodoWriteTool) { + // Extract todos from args (initial state) + let todos: Array<{ + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + }> = []; + + if (Array.isArray(args['todos'])) { + todos = args['todos'] as Array<{ + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + }>; + } + + // If returnDisplay has todos (e.g., modified by user), use those instead + if ( + toolResult.returnDisplay && + typeof toolResult.returnDisplay === 'object' && + 'type' in toolResult.returnDisplay && + toolResult.returnDisplay.type === 'todo_list' && + 'todos' in toolResult.returnDisplay && + Array.isArray(toolResult.returnDisplay.todos) + ) { + todos = toolResult.returnDisplay.todos; + } + + // Convert todos to plan entries and send plan update + if (todos.length > 0 || Array.isArray(args['todos'])) { + const planEntries = convertTodosToPlanEntries(todos); + await this.sendUpdate({ + sessionUpdate: 'plan', + entries: planEntries, + }); + } + + // Skip tool_call_update event for TodoWriteTool + // Still log and return function response for LLM + } else { + // Normal tool handling: send tool_call_update + const content = toToolCallContent(toolResult); + + await this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status: 'completed', + content: content ? [content] : [], + }); + } const durationMs = Date.now() - startTime; logToolCall(this.config, { @@ -500,6 +696,9 @@ class Session { return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); } catch (e) { + // Ensure cleanup on error + subAgentToolEventListeners.forEach((cleanup) => cleanup()); + const error = e instanceof Error ? e : new Error(String(e)); await this.sendUpdate({ @@ -515,6 +714,300 @@ class Session { } } + /** + * Sets up event listeners to track sub-agent tool calls within a TaskTool execution. + * Converts subagent tool call events into zedIntegration session updates. + * + * @param eventEmitter - The SubAgentEventEmitter from TaskTool + * @param abortSignal - Signal to abort tracking if parent is cancelled + * @returns Array of cleanup functions to remove event listeners + */ + private setupSubAgentToolTracking( + eventEmitter: SubAgentEventEmitter, + abortSignal: AbortSignal, + ): Array<() => void> { + const cleanupFunctions: Array<() => void> = []; + const toolRegistry = this.config.getToolRegistry(); + + // Track subagent tool call states + const subAgentToolStates = new Map< + string, + { + tool?: AnyDeclarativeTool; + invocation?: AnyToolInvocation; + args?: Record; + } + >(); + + // Listen for tool call start + const onToolCall = (...args: unknown[]) => { + const event = args[0] as SubAgentToolCallEvent; + if (abortSignal.aborted) return; + + const subAgentTool = toolRegistry.getTool(event.name); + let subAgentInvocation: AnyToolInvocation | undefined; + let toolKind: acp.ToolKind = 'other'; + let locations: acp.ToolCallLocation[] = []; + + if (subAgentTool) { + try { + subAgentInvocation = subAgentTool.build(event.args); + toolKind = this.mapToolKind(subAgentTool.kind); + locations = subAgentInvocation.toolLocations().map((loc) => ({ + path: loc.path, + line: loc.line ?? null, + })); + } catch (e) { + // If building fails, continue with defaults + console.warn(`Failed to build subagent tool ${event.name}:`, e); + } + } + + // Save state for subsequent updates + subAgentToolStates.set(event.callId, { + tool: subAgentTool, + invocation: subAgentInvocation, + args: event.args, + }); + + // Check if this is TodoWriteTool - if so, skip sending tool_call event + // Plan update will be sent in onToolResult when we have the final state + if (event.name === TodoWriteTool.Name) { + return; + } + + // Send tool call start update with rawInput + void this.sendUpdate({ + sessionUpdate: 'tool_call', + toolCallId: event.callId, + status: 'in_progress', + title: event.description || event.name, + content: [], + locations, + kind: toolKind, + rawInput: event.args, + }); + }; + + // Listen for tool call result + const onToolResult = (...args: unknown[]) => { + const event = args[0] as SubAgentToolResultEvent; + if (abortSignal.aborted) return; + + const state = subAgentToolStates.get(event.callId); + + // Check if this is TodoWriteTool - if so, route to plan updates + if (event.name === TodoWriteTool.Name) { + let todos: + | Array<{ + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + }> + | undefined; + + // Try to extract todos from resultDisplay first (final state) + if (event.resultDisplay) { + try { + // resultDisplay might be a JSON stringified object + const parsed = + typeof event.resultDisplay === 'string' + ? JSON.parse(event.resultDisplay) + : event.resultDisplay; + + if ( + typeof parsed === 'object' && + parsed !== null && + 'type' in parsed && + parsed.type === 'todo_list' && + 'todos' in parsed && + Array.isArray(parsed.todos) + ) { + todos = parsed.todos; + } + } catch { + // If parsing fails, ignore - resultDisplay might not be JSON + } + } + + // Fallback to args if resultDisplay doesn't have todos + if (!todos && state?.args && Array.isArray(state.args['todos'])) { + todos = state.args['todos'] as Array<{ + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + }>; + } + + // Send plan update if we have todos + if (todos) { + const planEntries = convertTodosToPlanEntries(todos); + void this.sendUpdate({ + sessionUpdate: 'plan', + entries: planEntries, + }); + } + + // Skip sending tool_call_update event for TodoWriteTool + // Clean up state + subAgentToolStates.delete(event.callId); + return; + } + + let content: acp.ToolCallContent[] = []; + + // If there's a result display, try to convert to ToolCallContent + if (event.resultDisplay && state?.invocation) { + // resultDisplay is typically a string + if (typeof event.resultDisplay === 'string') { + content = [ + { + type: 'content', + content: { + type: 'text', + text: event.resultDisplay, + }, + }, + ]; + } + } + + // Send tool call completion update + void this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: event.callId, + status: event.success ? 'completed' : 'failed', + content: content.length > 0 ? content : [], + title: state?.invocation?.getDescription() ?? event.name, + kind: state?.tool ? this.mapToolKind(state.tool.kind) : null, + locations: + state?.invocation?.toolLocations().map((loc) => ({ + path: loc.path, + line: loc.line ?? null, + })) ?? null, + rawInput: state?.args, + }); + + // Clean up state + subAgentToolStates.delete(event.callId); + }; + + // Listen for permission requests + const onToolWaitingApproval = async (...args: unknown[]) => { + const event = args[0] as SubAgentApprovalRequestEvent; + if (abortSignal.aborted) return; + + const state = subAgentToolStates.get(event.callId); + const content: acp.ToolCallContent[] = []; + + // Handle different confirmation types + if (event.confirmationDetails.type === 'edit') { + const editDetails = event.confirmationDetails as unknown as { + type: 'edit'; + fileName: string; + originalContent: string | null; + newContent: string; + }; + content.push({ + type: 'diff', + path: editDetails.fileName, + oldText: editDetails.originalContent ?? '', + newText: editDetails.newContent, + }); + } + + // Build permission request options from confirmation details + // event.confirmationDetails already contains all fields except onConfirm, + // which we add here to satisfy the type requirement for toPermissionOptions + const fullConfirmationDetails = { + ...event.confirmationDetails, + onConfirm: async () => { + // This is a placeholder - the actual response is handled via event.respond + }, + } as unknown as ToolCallConfirmationDetails; + + const params: acp.RequestPermissionRequest = { + sessionId: this.id, + options: toPermissionOptions(fullConfirmationDetails), + toolCall: { + toolCallId: event.callId, + status: 'pending', + title: event.description || event.name, + content, + locations: + state?.invocation?.toolLocations().map((loc) => ({ + path: loc.path, + line: loc.line ?? null, + })) ?? [], + kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other', + rawInput: state?.args, + }, + }; + + try { + // Request permission from zed client + const output = await this.client.requestPermission(params); + const outcome = + output.outcome.outcome === 'cancelled' + ? ToolConfirmationOutcome.Cancel + : z + .nativeEnum(ToolConfirmationOutcome) + .parse(output.outcome.optionId); + + // Respond to subagent with the outcome + await event.respond(outcome); + } catch (error) { + // If permission request fails, cancel the tool call + console.error( + `Permission request failed for subagent tool ${event.name}:`, + error, + ); + await event.respond(ToolConfirmationOutcome.Cancel); + } + }; + + // Register event listeners + eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); + eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); + eventEmitter.on( + SubAgentEventType.TOOL_WAITING_APPROVAL, + onToolWaitingApproval, + ); + + // Return cleanup functions + cleanupFunctions.push(() => { + eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); + eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); + eventEmitter.off( + SubAgentEventType.TOOL_WAITING_APPROVAL, + onToolWaitingApproval, + ); + }); + + return cleanupFunctions; + } + + /** + * Maps core Tool Kind enum to ACP ToolKind string literals. + * + * @param kind - The core Kind enum value + * @returns The corresponding ACP ToolKind string literal + */ + private mapToolKind(kind: Kind): acp.ToolKind { + const kindMap: Record = { + [Kind.Read]: 'read', + [Kind.Edit]: 'edit', + [Kind.Delete]: 'delete', + [Kind.Move]: 'move', + [Kind.Search]: 'search', + [Kind.Execute]: 'execute', + [Kind.Think]: 'think', + [Kind.Fetch]: 'fetch', + [Kind.Other]: 'other', + }; + return kindMap[kind] ?? 'other'; + } + async #resolvePrompt( message: acp.ContentBlock[], abortSignal: AbortSignal, @@ -859,6 +1352,27 @@ class Session { } } +/** + * Converts todo items to plan entries format for zed integration. + * Maps todo status to plan status and assigns a default priority. + * + * @param todos - Array of todo items with id, content, and status + * @returns Array of plan entries with content, priority, and status + */ +function convertTodosToPlanEntries( + todos: Array<{ + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + }>, +): acp.PlanEntry[] { + return todos.map((todo) => ({ + content: todo.content, + priority: 'medium' as const, // Default priority since todos don't have priority + status: todo.status, + })); +} + function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { if (toolResult.error?.message) { throw new Error(toolResult.error.message); @@ -870,26 +1384,6 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { type: 'content', content: { type: 'text', text: toolResult.returnDisplay }, }; - } else if ( - 'type' in toolResult.returnDisplay && - toolResult.returnDisplay.type === 'todo_list' - ) { - // Handle TodoResultDisplay - convert to text representation - const todoText = toolResult.returnDisplay.todos - .map((todo) => { - const statusIcon = { - pending: '○', - in_progress: '◐', - completed: '●', - }[todo.status]; - return `${statusIcon} ${todo.content}`; - }) - .join('\n'); - - return { - type: 'content', - content: { type: 'text', text: todoText }, - }; } else if ( 'type' in toolResult.returnDisplay && toolResult.returnDisplay.type === 'plan_summary' diff --git a/packages/core/package.json b/packages/core/package.json index 3b864724..3232a664 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.1.5", + "version": "0.2.2", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 72ecdc80..15ef951b 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -45,6 +45,15 @@ import { logRipgrepFallback } from '../telemetry/loggers.js'; import { RipgrepFallbackEvent } from '../telemetry/types.js'; import { ToolRegistry } from '../tools/tool-registry.js'; +function createToolMock(toolName: string) { + const ToolMock = vi.fn(); + Object.defineProperty(ToolMock, 'Name', { + value: toolName, + writable: true, + }); + return ToolMock; +} + vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { @@ -73,23 +82,41 @@ vi.mock('../utils/memoryDiscovery.js', () => ({ })); // Mock individual tools if their constructors are complex or have side effects -vi.mock('../tools/ls'); -vi.mock('../tools/read-file'); -vi.mock('../tools/grep.js'); +vi.mock('../tools/ls', () => ({ + LSTool: createToolMock('list_directory'), +})); +vi.mock('../tools/read-file', () => ({ + ReadFileTool: createToolMock('read_file'), +})); +vi.mock('../tools/grep.js', () => ({ + GrepTool: createToolMock('grep_search'), +})); vi.mock('../tools/ripGrep.js', () => ({ - RipGrepTool: class MockRipGrepTool {}, + RipGrepTool: createToolMock('grep_search'), })); vi.mock('../utils/ripgrepUtils.js', () => ({ canUseRipgrep: vi.fn(), })); -vi.mock('../tools/glob'); -vi.mock('../tools/edit'); -vi.mock('../tools/shell'); -vi.mock('../tools/write-file'); -vi.mock('../tools/web-fetch'); -vi.mock('../tools/read-many-files'); +vi.mock('../tools/glob', () => ({ + GlobTool: createToolMock('glob'), +})); +vi.mock('../tools/edit', () => ({ + EditTool: createToolMock('edit'), +})); +vi.mock('../tools/shell', () => ({ + ShellTool: createToolMock('run_shell_command'), +})); +vi.mock('../tools/write-file', () => ({ + WriteFileTool: createToolMock('write_file'), +})); +vi.mock('../tools/web-fetch', () => ({ + WebFetchTool: createToolMock('web_fetch'), +})); +vi.mock('../tools/read-many-files', () => ({ + ReadManyFilesTool: createToolMock('read_many_files'), +})); vi.mock('../tools/memoryTool', () => ({ - MemoryTool: vi.fn(), + MemoryTool: createToolMock('save_memory'), setGeminiMdFilename: vi.fn(), getCurrentGeminiMdFilename: vi.fn(() => 'QWEN.md'), // Mock the original filename DEFAULT_CONTEXT_FILENAME: 'QWEN.md', @@ -621,7 +648,7 @@ describe('Server Config (config.ts)', () => { it('should register a tool if coreTools contains an argument-specific pattern', async () => { const params: ConfigParameters = { ...baseParams, - coreTools: ['ShellTool(git status)'], + coreTools: ['Shell(git status)'], // Use display name instead of class name }; const config = new Config(params); await config.initialize(); @@ -646,6 +673,89 @@ describe('Server Config (config.ts)', () => { expect(wasReadFileToolRegistered).toBe(false); }); + it('should register a tool if coreTools contains the displayName', async () => { + const params: ConfigParameters = { + ...baseParams, + coreTools: ['Shell'], + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const wasShellToolRegistered = (registerToolMock as Mock).mock.calls.some( + (call) => call[0] instanceof vi.mocked(ShellTool), + ); + expect(wasShellToolRegistered).toBe(true); + }); + + it('should register a tool if coreTools contains the displayName with argument-specific pattern', async () => { + const params: ConfigParameters = { + ...baseParams, + coreTools: ['Shell(git status)'], + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const wasShellToolRegistered = (registerToolMock as Mock).mock.calls.some( + (call) => call[0] instanceof vi.mocked(ShellTool), + ); + expect(wasShellToolRegistered).toBe(true); + }); + + it('should register a tool if coreTools contains a legacy tool name alias', async () => { + const params: ConfigParameters = { + ...baseParams, + useRipgrep: false, + coreTools: ['search_file_content'], + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const wasGrepToolRegistered = (registerToolMock as Mock).mock.calls.some( + (call) => call[0] instanceof vi.mocked(GrepTool), + ); + expect(wasGrepToolRegistered).toBe(true); + }); + + it('should not register a tool if excludeTools contains a legacy display name alias', async () => { + const params: ConfigParameters = { + ...baseParams, + useRipgrep: false, + coreTools: undefined, + excludeTools: ['SearchFiles'], + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const wasGrepToolRegistered = (registerToolMock as Mock).mock.calls.some( + (call) => call[0] instanceof vi.mocked(GrepTool), + ); + expect(wasGrepToolRegistered).toBe(false); + }); + describe('with minified tool class names', () => { beforeEach(() => { Object.defineProperty( @@ -671,7 +781,27 @@ describe('Server Config (config.ts)', () => { it('should register a tool if coreTools contains the non-minified class name', async () => { const params: ConfigParameters = { ...baseParams, - coreTools: ['ShellTool'], + coreTools: ['Shell'], // Use display name instead of class name + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const wasShellToolRegistered = ( + registerToolMock as Mock + ).mock.calls.some((call) => call[0] instanceof vi.mocked(ShellTool)); + expect(wasShellToolRegistered).toBe(true); + }); + + it('should register a tool if coreTools contains the displayName', async () => { + const params: ConfigParameters = { + ...baseParams, + coreTools: ['Shell'], }; const config = new Config(params); await config.initialize(); @@ -692,7 +822,28 @@ describe('Server Config (config.ts)', () => { const params: ConfigParameters = { ...baseParams, coreTools: undefined, // all tools enabled by default - excludeTools: ['ShellTool'], + excludeTools: ['Shell'], // Use display name instead of class name + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const wasShellToolRegistered = ( + registerToolMock as Mock + ).mock.calls.some((call) => call[0] instanceof vi.mocked(ShellTool)); + expect(wasShellToolRegistered).toBe(false); + }); + + it('should not register a tool if excludeTools contains the displayName', async () => { + const params: ConfigParameters = { + ...baseParams, + coreTools: undefined, // all tools enabled by default + excludeTools: ['Shell'], }; const config = new Config(params); await config.initialize(); @@ -712,7 +863,27 @@ describe('Server Config (config.ts)', () => { it('should register a tool if coreTools contains an argument-specific pattern with the non-minified class name', async () => { const params: ConfigParameters = { ...baseParams, - coreTools: ['ShellTool(git status)'], + coreTools: ['Shell(git status)'], // Use display name instead of class name + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const wasShellToolRegistered = ( + registerToolMock as Mock + ).mock.calls.some((call) => call[0] instanceof vi.mocked(ShellTool)); + expect(wasShellToolRegistered).toBe(true); + }); + + it('should register a tool if coreTools contains an argument-specific pattern with the displayName', async () => { + const params: ConfigParameters = { + ...baseParams, + coreTools: ['Shell(git status)'], }; const config = new Config(params); await config.initialize(); @@ -738,13 +909,13 @@ describe('Server Config (config.ts)', () => { it('should return the calculated threshold when it is smaller than the default', () => { const config = new Config(baseParams); - vi.mocked(tokenLimit).mockReturnValue(32000); + vi.mocked(tokenLimit).mockReturnValue(8000); vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue( - 1000, + 2000, ); - // 4 * (32000 - 1000) = 4 * 31000 = 124000 - // default is 4_000_000 - expect(config.getTruncateToolOutputThreshold()).toBe(124000); + // 4 * (8000 - 2000) = 4 * 6000 = 24000 + // default is 25_000 + expect(config.getTruncateToolOutputThreshold()).toBe(24000); }); it('should return the default threshold when the calculated value is larger', () => { @@ -754,8 +925,8 @@ describe('Server Config (config.ts)', () => { 500_000, ); // 4 * (2_000_000 - 500_000) = 4 * 1_500_000 = 6_000_000 - // default is 4_000_000 - expect(config.getTruncateToolOutputThreshold()).toBe(4_000_000); + // default is 25_000 + expect(config.getTruncateToolOutputThreshold()).toBe(25_000); }); it('should use a custom truncateToolOutputThreshold if provided', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 69935ca7..4ed23060 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -81,6 +81,7 @@ import { import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { FileExclusions } from '../utils/ignorePatterns.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { isToolEnabled, type ToolName } from '../utils/tool-utils.js'; // Local config modules import type { FileFilteringOptions } from './constants.js'; @@ -161,7 +162,7 @@ export interface ExtensionInstallMetadata { autoUpdate?: boolean; } -export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 4_000_000; +export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000; export const DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES = 1000; export class MCPServerConfig { @@ -291,6 +292,7 @@ export interface ConfigParameters { output?: OutputSettings; inputFormat?: InputFormat; outputFormat?: OutputFormat; + skipStartupContext?: boolean; } function normalizeConfigOutputFormat( @@ -402,6 +404,7 @@ export class Config { private readonly extensionManagement: boolean = true; private readonly enablePromptCompletion: boolean = false; private readonly skipLoopDetection: boolean; + private readonly skipStartupContext: boolean; private readonly vlmSwitchMode: string | undefined; private initialized: boolean = false; readonly storage: Storage; @@ -499,6 +502,7 @@ export class Config { this.interactive = params.interactive ?? false; this.trustedFolder = params.trustedFolder; this.skipLoopDetection = params.skipLoopDetection ?? false; + this.skipStartupContext = params.skipStartupContext ?? false; // Web search this.webSearch = params.webSearch; @@ -1076,6 +1080,10 @@ export class Config { return this.skipLoopDetection; } + getSkipStartupContext(): boolean { + return this.skipStartupContext; + } + getVlmSwitchMode(): string | undefined { return this.vlmSwitchMode; } @@ -1085,6 +1093,13 @@ export class Config { } getTruncateToolOutputThreshold(): number { + if ( + !this.enableToolOutputTruncation || + this.truncateToolOutputThreshold <= 0 + ) { + return Number.POSITIVE_INFINITY; + } + return Math.min( // Estimate remaining context window in characters (1 token ~= 4 chars). 4 * @@ -1095,6 +1110,10 @@ export class Config { } getTruncateToolOutputLines(): number { + if (!this.enableToolOutputTruncation || this.truncateToolOutputLines <= 0) { + return Number.POSITIVE_INFINITY; + } + return this.truncateToolOutputLines; } @@ -1125,37 +1144,35 @@ export class Config { async createToolRegistry(): Promise { const registry = new ToolRegistry(this, this.eventEmitter); - // helper to create & register core tools that are enabled + const coreToolsConfig = this.getCoreTools(); + const excludeToolsConfig = this.getExcludeTools(); + + // Helper to create & register core tools that are enabled // eslint-disable-next-line @typescript-eslint/no-explicit-any const registerCoreTool = (ToolClass: any, ...args: unknown[]) => { - const className = ToolClass.name; - const toolName = ToolClass.Name || className; - const coreTools = this.getCoreTools(); - const excludeTools = this.getExcludeTools() || []; - // On some platforms, the className can be minified to _ClassName. - const normalizedClassName = className.replace(/^_+/, ''); + const toolName = ToolClass?.Name as ToolName | undefined; + const className = ToolClass?.name ?? 'UnknownTool'; - let isEnabled = true; // Enabled by default if coreTools is not set. - if (coreTools) { - isEnabled = coreTools.some( - (tool) => - tool === toolName || - tool === normalizedClassName || - tool.startsWith(`${toolName}(`) || - tool.startsWith(`${normalizedClassName}(`), + if (!toolName) { + // Log warning and skip this tool instead of crashing + console.warn( + `[Config] Skipping tool registration: ${className} is missing static Name property. ` + + `Tools must define a static Name property to be registered. ` + + `Location: config.ts:registerCoreTool`, ); + return; } - const isExcluded = excludeTools.some( - (tool) => tool === toolName || tool === normalizedClassName, - ); - - if (isExcluded) { - isEnabled = false; - } - - if (isEnabled) { - registry.registerTool(new ToolClass(...args)); + if (isToolEnabled(toolName, coreToolsConfig, excludeToolsConfig)) { + try { + registry.registerTool(new ToolClass(...args)); + } catch (error) { + console.error( + `[Config] Failed to register tool ${className} (${toolName}):`, + error, + ); + throw error; // Re-throw after logging context + } } }; diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 9294835a..d0bf1aa8 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -1789,6 +1789,268 @@ describe('CoreToolScheduler request queueing', () => { }); }); +describe('CoreToolScheduler Sequential Execution', () => { + it('should execute tool calls in a batch sequentially', async () => { + // Arrange + let firstCallFinished = false; + const executeFn = vi + .fn() + .mockImplementation(async (args: { call: number }) => { + if (args.call === 1) { + // First call, wait for a bit to simulate work + await new Promise((resolve) => setTimeout(resolve, 50)); + firstCallFinished = true; + return { llmContent: 'First call done' }; + } + if (args.call === 2) { + // Second call, should only happen after the first is finished + if (!firstCallFinished) { + throw new Error( + 'Second tool call started before the first one finished!', + ); + } + return { llmContent: 'Second call done' }; + } + return { llmContent: 'default' }; + }); + + const mockTool = new MockTool({ name: 'mockTool', execute: executeFn }); + const declarativeTool = mockTool; + + const mockToolRegistry = { + getTool: () => declarativeTool, + getToolByName: () => declarativeTool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByDisplayName: () => declarativeTool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.YOLO, // Use YOLO to avoid confirmation prompts + getAllowedTools: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getToolRegistry: () => mockToolRegistry, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getUseSmartEdit: () => false, + getUseModelRouter: () => false, + getGeminiClient: () => null, + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + const abortController = new AbortController(); + const requests = [ + { + callId: '1', + name: 'mockTool', + args: { call: 1 }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + { + callId: '2', + name: 'mockTool', + args: { call: 2 }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + ]; + + // Act + await scheduler.schedule(requests, abortController.signal); + + // Assert + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + + // Check that execute was called twice + expect(executeFn).toHaveBeenCalledTimes(2); + + // Check the order of calls + const calls = executeFn.mock.calls; + expect(calls[0][0]).toEqual({ call: 1 }); + expect(calls[1][0]).toEqual({ call: 2 }); + + // The onAllToolCallsComplete should be called once with both results + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(2); + expect(completedCalls[0].status).toBe('success'); + expect(completedCalls[1].status).toBe('success'); + }); + + it('should cancel subsequent tools when the signal is aborted.', async () => { + // Arrange + const abortController = new AbortController(); + let secondCallStarted = false; + + const executeFn = vi + .fn() + .mockImplementation(async (args: { call: number }) => { + if (args.call === 1) { + return { llmContent: 'First call done' }; + } + if (args.call === 2) { + secondCallStarted = true; + // This call will be cancelled while it's "running". + await new Promise((resolve) => setTimeout(resolve, 100)); + // It should not return a value because it will be cancelled. + return { llmContent: 'Second call should not complete' }; + } + if (args.call === 3) { + return { llmContent: 'Third call done' }; + } + return { llmContent: 'default' }; + }); + + const mockTool = new MockTool({ name: 'mockTool', execute: executeFn }); + const declarativeTool = mockTool; + + const mockToolRegistry = { + getTool: () => declarativeTool, + getToolByName: () => declarativeTool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByDisplayName: () => declarativeTool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.YOLO, + getAllowedTools: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getToolRegistry: () => mockToolRegistry, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getUseSmartEdit: () => false, + getUseModelRouter: () => false, + getGeminiClient: () => null, + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + const requests = [ + { + callId: '1', + name: 'mockTool', + args: { call: 1 }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + { + callId: '2', + name: 'mockTool', + args: { call: 2 }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + { + callId: '3', + name: 'mockTool', + args: { call: 3 }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + ]; + + // Act + const schedulePromise = scheduler.schedule( + requests, + abortController.signal, + ); + + // Wait for the second call to start, then abort. + await vi.waitFor(() => { + expect(secondCallStarted).toBe(true); + }); + abortController.abort(); + + await schedulePromise; + + // Assert + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + + // Check that execute was called for all three tools initially + expect(executeFn).toHaveBeenCalledTimes(3); + expect(executeFn).toHaveBeenCalledWith({ call: 1 }); + expect(executeFn).toHaveBeenCalledWith({ call: 2 }); + expect(executeFn).toHaveBeenCalledWith({ call: 3 }); + + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(3); + + const call1 = completedCalls.find((c) => c.request.callId === '1'); + const call2 = completedCalls.find((c) => c.request.callId === '2'); + const call3 = completedCalls.find((c) => c.request.callId === '3'); + + expect(call1?.status).toBe('success'); + expect(call2?.status).toBe('cancelled'); + expect(call3?.status).toBe('cancelled'); + }); +}); + describe('truncateAndSaveToFile', () => { const mockWriteFile = vi.mocked(fs.writeFile); const THRESHOLD = 40_000; @@ -1968,14 +2230,14 @@ describe('truncateAndSaveToFile', () => { ); expect(result.content).toContain( - 'read_file tool with the absolute file path above', + 'Tool output was too large and has been truncated', ); - expect(result.content).toContain('read_file tool with offset=0, limit=100'); + expect(result.content).toContain('The full output has been saved to:'); expect(result.content).toContain( - 'read_file tool with offset=N to skip N lines', + 'To read the complete output, use the read_file tool with the absolute file path above', ); expect(result.content).toContain( - 'read_file tool with limit=M to read only M lines', + 'The truncated output below shows the beginning and end of the content', ); }); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 9ab57f2c..8334ce5a 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -299,10 +299,7 @@ export async function truncateAndSaveToFile( return { content: `Tool output was too large and has been truncated. The full output has been saved to: ${outputFile} -To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. For large files, you can use the offset and limit parameters to read specific sections: -- ${ReadFileTool.Name} tool with offset=0, limit=100 to see the first 100 lines -- ${ReadFileTool.Name} tool with offset=N to skip N lines from the beginning -- ${ReadFileTool.Name} tool with limit=M to read only M lines at a time +To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed. This allows you to efficiently examine different parts of the output without loading the entire file. Truncated part of the output: @@ -903,7 +900,7 @@ export class CoreToolScheduler { ); } } - this.attemptExecutionOfScheduledCalls(signal); + await this.attemptExecutionOfScheduledCalls(signal); void this.checkAndNotifyCompletion(); } finally { this.isScheduling = false; @@ -978,7 +975,7 @@ export class CoreToolScheduler { } this.setStatusInternal(callId, 'scheduled'); } - this.attemptExecutionOfScheduledCalls(signal); + await this.attemptExecutionOfScheduledCalls(signal); } /** @@ -1024,7 +1021,9 @@ export class CoreToolScheduler { }); } - private attemptExecutionOfScheduledCalls(signal: AbortSignal): void { + private async attemptExecutionOfScheduledCalls( + signal: AbortSignal, + ): Promise { const allCallsFinalOrScheduled = this.toolCalls.every( (call) => call.status === 'scheduled' || @@ -1038,8 +1037,8 @@ export class CoreToolScheduler { (call) => call.status === 'scheduled', ); - callsToExecute.forEach((toolCall) => { - if (toolCall.status !== 'scheduled') return; + for (const toolCall of callsToExecute) { + if (toolCall.status !== 'scheduled') continue; const scheduledCall = toolCall; const { callId, name: toolName } = scheduledCall.request; @@ -1090,107 +1089,106 @@ export class CoreToolScheduler { ); } - promise - .then(async (toolResult: ToolResult) => { - if (signal.aborted) { - this.setStatusInternal( - callId, - 'cancelled', - 'User cancelled tool execution.', - ); - return; - } + try { + const toolResult: ToolResult = await promise; + if (signal.aborted) { + this.setStatusInternal( + callId, + 'cancelled', + 'User cancelled tool execution.', + ); + continue; + } - if (toolResult.error === undefined) { - let content = toolResult.llmContent; - let outputFile: string | undefined = undefined; - const contentLength = - typeof content === 'string' ? content.length : undefined; - if ( - typeof content === 'string' && - toolName === ShellTool.Name && - this.config.getEnableToolOutputTruncation() && - this.config.getTruncateToolOutputThreshold() > 0 && - this.config.getTruncateToolOutputLines() > 0 - ) { - const originalContentLength = content.length; - const threshold = this.config.getTruncateToolOutputThreshold(); - const lines = this.config.getTruncateToolOutputLines(); - const truncatedResult = await truncateAndSaveToFile( - content, - callId, - this.config.storage.getProjectTempDir(), - threshold, - lines, - ); - content = truncatedResult.content; - outputFile = truncatedResult.outputFile; - - if (outputFile) { - logToolOutputTruncated( - this.config, - new ToolOutputTruncatedEvent( - scheduledCall.request.prompt_id, - { - toolName, - originalContentLength, - truncatedContentLength: content.length, - threshold, - lines, - }, - ), - ); - } - } - - const response = convertToFunctionResponse( - toolName, - callId, + if (toolResult.error === undefined) { + let content = toolResult.llmContent; + let outputFile: string | undefined = undefined; + const contentLength = + typeof content === 'string' ? content.length : undefined; + if ( + typeof content === 'string' && + toolName === ShellTool.Name && + this.config.getEnableToolOutputTruncation() && + this.config.getTruncateToolOutputThreshold() > 0 && + this.config.getTruncateToolOutputLines() > 0 + ) { + const originalContentLength = content.length; + const threshold = this.config.getTruncateToolOutputThreshold(); + const lines = this.config.getTruncateToolOutputLines(); + const truncatedResult = await truncateAndSaveToFile( content, - ); - const successResponse: ToolCallResponseInfo = { callId, - responseParts: response, - resultDisplay: toolResult.returnDisplay, - error: undefined, - errorType: undefined, - outputFile, - contentLength, - }; - this.setStatusInternal(callId, 'success', successResponse); - } else { - // It is a failure - const error = new Error(toolResult.error.message); - const errorResponse = createErrorResponse( + this.config.storage.getProjectTempDir(), + threshold, + lines, + ); + content = truncatedResult.content; + outputFile = truncatedResult.outputFile; + + if (outputFile) { + logToolOutputTruncated( + this.config, + new ToolOutputTruncatedEvent( + scheduledCall.request.prompt_id, + { + toolName, + originalContentLength, + truncatedContentLength: content.length, + threshold, + lines, + }, + ), + ); + } + } + + const response = convertToFunctionResponse( + toolName, + callId, + content, + ); + const successResponse: ToolCallResponseInfo = { + callId, + responseParts: response, + resultDisplay: toolResult.returnDisplay, + error: undefined, + errorType: undefined, + outputFile, + contentLength, + }; + this.setStatusInternal(callId, 'success', successResponse); + } else { + // It is a failure + const error = new Error(toolResult.error.message); + const errorResponse = createErrorResponse( + scheduledCall.request, + error, + toolResult.error.type, + ); + this.setStatusInternal(callId, 'error', errorResponse); + } + } catch (executionError: unknown) { + if (signal.aborted) { + this.setStatusInternal( + callId, + 'cancelled', + 'User cancelled tool execution.', + ); + } else { + this.setStatusInternal( + callId, + 'error', + createErrorResponse( scheduledCall.request, - error, - toolResult.error.type, - ); - this.setStatusInternal(callId, 'error', errorResponse); - } - }) - .catch((executionError: Error) => { - if (signal.aborted) { - this.setStatusInternal( - callId, - 'cancelled', - 'User cancelled tool execution.', - ); - } else { - this.setStatusInternal( - callId, - 'error', - createErrorResponse( - scheduledCall.request, - executionError instanceof Error - ? executionError - : new Error(String(executionError)), - ToolErrorType.UNHANDLED_EXCEPTION, - ), - ); - } - }); - }); + executionError instanceof Error + ? executionError + : new Error(String(executionError)), + ToolErrorType.UNHANDLED_EXCEPTION, + ), + ); + } + } + } } } diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 7d4314b7..94ef927d 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -23,8 +23,6 @@ import { setSimulate429 } from '../utils/testUtils.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { AuthType } from './contentGenerator.js'; import { type RetryOptions } from '../utils/retry.js'; -import type { ToolRegistry } from '../tools/tool-registry.js'; -import { Kind } from '../tools/tools.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; // Mock fs module to prevent actual file system operations during tests @@ -1305,259 +1303,6 @@ describe('GeminiChat', () => { expect(turn4.parts[0].text).toBe('second response'); }); - describe('stopBeforeSecondMutator', () => { - beforeEach(() => { - // Common setup for these tests: mock the tool registry. - const mockToolRegistry = { - getTool: vi.fn((toolName: string) => { - if (toolName === 'edit') { - return { kind: Kind.Edit }; - } - return { kind: Kind.Other }; - }), - } as unknown as ToolRegistry; - vi.mocked(mockConfig.getToolRegistry).mockReturnValue(mockToolRegistry); - }); - - it('should stop streaming before a second mutator tool call', async () => { - const responses = [ - { - candidates: [ - { content: { role: 'model', parts: [{ text: 'First part. ' }] } }, - ], - }, - { - candidates: [ - { - content: { - role: 'model', - parts: [{ functionCall: { name: 'edit', args: {} } }], - }, - }, - ], - }, - { - candidates: [ - { - content: { - role: 'model', - parts: [{ functionCall: { name: 'fetch', args: {} } }], - }, - }, - ], - }, - // This chunk contains the second mutator and should be clipped. - { - candidates: [ - { - content: { - role: 'model', - parts: [ - { functionCall: { name: 'edit', args: {} } }, - { text: 'some trailing text' }, - ], - }, - }, - ], - }, - // This chunk should never be reached. - { - candidates: [ - { - content: { - role: 'model', - parts: [{ text: 'This should not appear.' }], - }, - }, - ], - }, - ] as unknown as GenerateContentResponse[]; - - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - (async function* () { - for (const response of responses) { - yield response; - } - })(), - ); - - const stream = await chat.sendMessageStream( - 'test-model', - { message: 'test message' }, - 'prompt-id-mutator-test', - ); - for await (const _ of stream) { - // Consume the stream to trigger history recording. - } - - const history = chat.getHistory(); - expect(history.length).toBe(2); - - const modelTurn = history[1]!; - expect(modelTurn.role).toBe('model'); - expect(modelTurn?.parts?.length).toBe(3); - expect(modelTurn?.parts![0]!.text).toBe('First part. '); - expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit'); - expect(modelTurn.parts![2]!.functionCall?.name).toBe('fetch'); - }); - - it('should not stop streaming if only one mutator is present', async () => { - const responses = [ - { - candidates: [ - { content: { role: 'model', parts: [{ text: 'Part 1. ' }] } }, - ], - }, - { - candidates: [ - { - content: { - role: 'model', - parts: [{ functionCall: { name: 'edit', args: {} } }], - }, - }, - ], - }, - { - candidates: [ - { - content: { - role: 'model', - parts: [{ text: 'Part 2.' }], - }, - finishReason: 'STOP', - }, - ], - }, - ] as unknown as GenerateContentResponse[]; - - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - (async function* () { - for (const response of responses) { - yield response; - } - })(), - ); - - const stream = await chat.sendMessageStream( - 'test-model', - { message: 'test message' }, - 'prompt-id-one-mutator', - ); - for await (const _ of stream) { - /* consume */ - } - - const history = chat.getHistory(); - const modelTurn = history[1]!; - expect(modelTurn?.parts?.length).toBe(3); - expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit'); - expect(modelTurn.parts![2]!.text).toBe('Part 2.'); - }); - - it('should clip the chunk containing the second mutator, preserving prior parts', async () => { - const responses = [ - { - candidates: [ - { - content: { - role: 'model', - parts: [{ functionCall: { name: 'edit', args: {} } }], - }, - }, - ], - }, - // This chunk has a valid part before the second mutator. - // The valid part should be kept, the rest of the chunk discarded. - { - candidates: [ - { - content: { - role: 'model', - parts: [ - { text: 'Keep this text. ' }, - { functionCall: { name: 'edit', args: {} } }, - { text: 'Discard this text.' }, - ], - }, - finishReason: 'STOP', - }, - ], - }, - ] as unknown as GenerateContentResponse[]; - - const stream = (async function* () { - for (const response of responses) { - yield response; - } - })(); - - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - stream, - ); - - const resultStream = await chat.sendMessageStream( - 'test-model', - { message: 'test' }, - 'prompt-id-clip-chunk', - ); - for await (const _ of resultStream) { - /* consume */ - } - - const history = chat.getHistory(); - const modelTurn = history[1]!; - expect(modelTurn?.parts?.length).toBe(2); - expect(modelTurn.parts![0]!.functionCall?.name).toBe('edit'); - expect(modelTurn.parts![1]!.text).toBe('Keep this text. '); - }); - - it('should handle two mutators in the same chunk (parallel call scenario)', async () => { - const responses = [ - { - candidates: [ - { - content: { - role: 'model', - parts: [ - { text: 'Some text. ' }, - { functionCall: { name: 'edit', args: {} } }, - { functionCall: { name: 'edit', args: {} } }, - ], - }, - finishReason: 'STOP', - }, - ], - }, - ] as unknown as GenerateContentResponse[]; - - const stream = (async function* () { - for (const response of responses) { - yield response; - } - })(); - - vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( - stream, - ); - - const resultStream = await chat.sendMessageStream( - 'test-model', - { message: 'test' }, - 'prompt-id-parallel-mutators', - ); - for await (const _ of resultStream) { - /* consume */ - } - - const history = chat.getHistory(); - const modelTurn = history[1]!; - expect(modelTurn?.parts?.length).toBe(2); - expect(modelTurn.parts![0]!.text).toBe('Some text. '); - expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit'); - }); - }); - describe('Model Resolution', () => { const mockResponse = { candidates: [ diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index b21a7023..79249733 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -7,16 +7,15 @@ // DISCLAIMER: This is a copied version of https://github.com/googleapis/js-genai/blob/main/src/chats.ts with the intention of working around a key bug // where function responses are not treated as "valid" responses: https://b.corp.google.com/issues/420354090 -import { +import type { GenerateContentResponse, - type Content, - type GenerateContentConfig, - type SendMessageParameters, - type Part, - type Tool, - FinishReason, - ApiError, + Content, + GenerateContentConfig, + SendMessageParameters, + Part, + Tool, } from '@google/genai'; +import { ApiError } from '@google/genai'; import { toParts } from '../code_assist/converter.js'; import { createUserContent } from '@google/genai'; import { retryWithBackoff } from '../utils/retry.js'; @@ -25,7 +24,7 @@ import { DEFAULT_GEMINI_FLASH_MODEL, getEffectiveModel, } from '../config/models.js'; -import { hasCycleInSchema, MUTATOR_KINDS } from '../tools/tools.js'; +import { hasCycleInSchema } from '../tools/tools.js'; import type { StructuredError } from './turn.js'; import { logContentRetry, @@ -511,7 +510,7 @@ export class GeminiChat { let hasToolCall = false; let hasFinishReason = false; - for await (const chunk of this.stopBeforeSecondMutator(streamResponse)) { + for await (const chunk of streamResponse) { hasFinishReason = chunk?.candidates?.some((candidate) => candidate.finishReason) ?? false; if (isValidResponse(chunk)) { @@ -629,64 +628,6 @@ export class GeminiChat { }); } } - - /** - * Truncates the chunkStream right before the second function call to a - * function that mutates state. This may involve trimming parts from a chunk - * as well as omtting some chunks altogether. - * - * We do this because it improves tool call quality if the model gets - * feedback from one mutating function call before it makes the next one. - */ - private async *stopBeforeSecondMutator( - chunkStream: AsyncGenerator, - ): AsyncGenerator { - let foundMutatorFunctionCall = false; - - for await (const chunk of chunkStream) { - const candidate = chunk.candidates?.[0]; - const content = candidate?.content; - if (!candidate || !content?.parts) { - yield chunk; - continue; - } - - const truncatedParts: Part[] = []; - for (const part of content.parts) { - if (this.isMutatorFunctionCall(part)) { - if (foundMutatorFunctionCall) { - // This is the second mutator call. - // Truncate and return immedaitely. - const newChunk = new GenerateContentResponse(); - newChunk.candidates = [ - { - ...candidate, - content: { - ...content, - parts: truncatedParts, - }, - finishReason: FinishReason.STOP, - }, - ]; - yield newChunk; - return; - } - foundMutatorFunctionCall = true; - } - truncatedParts.push(part); - } - - yield chunk; - } - } - - private isMutatorFunctionCall(part: Part): boolean { - if (!part?.functionCall?.name) { - return false; - } - const tool = this.config.getToolRegistry().getTool(part.functionCall.name); - return !!tool && MUTATOR_KINDS.includes(tool.kind); - } } /** Visible for Testing */ diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 7966f384..1edbdd6e 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -23,6 +23,14 @@ import type OpenAI from 'openai'; import { safeJsonParse } from '../../utils/safeJsonParse.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; +/** + * Extended usage type that supports both OpenAI standard format and alternative formats + * Some models return cached_tokens at the top level instead of in prompt_tokens_details + */ +interface ExtendedCompletionUsage extends OpenAI.CompletionUsage { + cached_tokens?: number; +} + /** * Tool call accumulator for streaming responses */ @@ -582,7 +590,13 @@ export class OpenAIContentConverter { const promptTokens = usage.prompt_tokens || 0; const completionTokens = usage.completion_tokens || 0; const totalTokens = usage.total_tokens || 0; - const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0; + // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard) + // and cached_tokens (some models return it at top level) + const extendedUsage = usage as ExtendedCompletionUsage; + const cachedTokens = + usage.prompt_tokens_details?.cached_tokens ?? + extendedUsage.cached_tokens ?? + 0; // If we only have total tokens but no breakdown, estimate the split // Typically input is ~70% and output is ~30% for most conversations @@ -707,7 +721,13 @@ export class OpenAIContentConverter { const promptTokens = usage.prompt_tokens || 0; const completionTokens = usage.completion_tokens || 0; const totalTokens = usage.total_tokens || 0; - const cachedTokens = usage.prompt_tokens_details?.cached_tokens || 0; + // Support both formats: prompt_tokens_details.cached_tokens (OpenAI standard) + // and cached_tokens (some models return it at top level) + const extendedUsage = usage as ExtendedCompletionUsage; + const cachedTokens = + usage.prompt_tokens_details?.cached_tokens ?? + extendedUsage.cached_tokens ?? + 0; // If we only have total tokens but no breakdown, estimate the split // Typically input is ~70% and output is ~30% for most conversations diff --git a/packages/core/src/core/openaiContentGenerator/index.ts b/packages/core/src/core/openaiContentGenerator/index.ts index 192bf096..8559258c 100644 --- a/packages/core/src/core/openaiContentGenerator/index.ts +++ b/packages/core/src/core/openaiContentGenerator/index.ts @@ -13,6 +13,7 @@ import { OpenAIContentGenerator } from './openaiContentGenerator.js'; import { DashScopeOpenAICompatibleProvider, DeepSeekOpenAICompatibleProvider, + ModelScopeOpenAICompatibleProvider, OpenRouterOpenAICompatibleProvider, type OpenAICompatibleProvider, DefaultOpenAICompatibleProvider, @@ -78,6 +79,14 @@ export function determineProvider( ); } + // Check for ModelScope provider + if (ModelScopeOpenAICompatibleProvider.isModelScopeProvider(config)) { + return new ModelScopeOpenAICompatibleProvider( + contentGeneratorConfig, + cliConfig, + ); + } + // Default provider for standard OpenAI-compatible APIs return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/index.ts b/packages/core/src/core/openaiContentGenerator/provider/index.ts index 9886b70f..cb33834d 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/index.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/index.ts @@ -1,3 +1,4 @@ +export { ModelScopeOpenAICompatibleProvider } from './modelscope.js'; export { DashScopeOpenAICompatibleProvider } from './dashscope.js'; export { DeepSeekOpenAICompatibleProvider } from './deepseek.js'; export { OpenRouterOpenAICompatibleProvider } from './openrouter.js'; diff --git a/packages/core/src/core/openaiContentGenerator/provider/modelscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/modelscope.test.ts new file mode 100644 index 00000000..da5a71a8 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/modelscope.test.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type OpenAI from 'openai'; +import { ModelScopeOpenAICompatibleProvider } from './modelscope.js'; +import type { Config } from '../../../config/config.js'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; + +vi.mock('openai'); + +describe('ModelScopeOpenAICompatibleProvider', () => { + let provider: ModelScopeOpenAICompatibleProvider; + let mockContentGeneratorConfig: ContentGeneratorConfig; + let mockCliConfig: Config; + + beforeEach(() => { + mockContentGeneratorConfig = { + apiKey: 'test-api-key', + baseUrl: 'https://api.modelscope.cn/v1', + model: 'qwen-max', + } as ContentGeneratorConfig; + + mockCliConfig = { + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + } as unknown as Config; + + provider = new ModelScopeOpenAICompatibleProvider( + mockContentGeneratorConfig, + mockCliConfig, + ); + }); + + describe('isModelScopeProvider', () => { + it('should return true if baseUrl includes "modelscope"', () => { + const config = { baseUrl: 'https://api.modelscope.cn/v1' }; + expect( + ModelScopeOpenAICompatibleProvider.isModelScopeProvider( + config as ContentGeneratorConfig, + ), + ).toBe(true); + }); + + it('should return false if baseUrl does not include "modelscope"', () => { + const config = { baseUrl: 'https://api.openai.com/v1' }; + expect( + ModelScopeOpenAICompatibleProvider.isModelScopeProvider( + config as ContentGeneratorConfig, + ), + ).toBe(false); + }); + }); + + describe('buildRequest', () => { + it('should remove stream_options when stream is false', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'qwen-max', + messages: [{ role: 'user', content: 'Hello!' }], + stream: false, + stream_options: { include_usage: true }, + }; + + const result = provider.buildRequest(originalRequest, 'prompt-id'); + + expect(result).not.toHaveProperty('stream_options'); + }); + + it('should keep stream_options when stream is true', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'qwen-max', + messages: [{ role: 'user', content: 'Hello!' }], + stream: true, + stream_options: { include_usage: true }, + }; + + const result = provider.buildRequest(originalRequest, 'prompt-id'); + + expect(result).toHaveProperty('stream_options'); + }); + + it('should handle requests without stream_options', () => { + const originalRequest: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'qwen-max', + messages: [{ role: 'user', content: 'Hello!' }], + stream: false, + }; + + const result = provider.buildRequest(originalRequest, 'prompt-id'); + + expect(result).not.toHaveProperty('stream_options'); + }); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator/provider/modelscope.ts b/packages/core/src/core/openaiContentGenerator/provider/modelscope.ts new file mode 100644 index 00000000..1afb2d03 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/modelscope.ts @@ -0,0 +1,32 @@ +import type OpenAI from 'openai'; +import { DefaultOpenAICompatibleProvider } from './default.js'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; + +/** + * Provider for ModelScope API + */ +export class ModelScopeOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider { + /** + * Checks if the configuration is for ModelScope. + */ + static isModelScopeProvider(config: ContentGeneratorConfig): boolean { + return !!config.baseUrl?.includes('modelscope'); + } + + /** + * ModelScope does not support `stream_options` when `stream` is false. + * This method removes `stream_options` if `stream` is not true. + */ + override buildRequest( + request: OpenAI.Chat.ChatCompletionCreateParams, + userPromptId: string, + ): OpenAI.Chat.ChatCompletionCreateParams { + const newRequest = super.buildRequest(request, userPromptId); + if (!newRequest.stream) { + delete (newRequest as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming) + .stream_options; + } + + return newRequest; + } +} diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index f2693075..91471580 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -165,9 +165,7 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ // ------------------- // DeepSeek // ------------------- - [/^deepseek$/, LIMITS['128k']], - [/^deepseek-r1(?:-.*)?$/, LIMITS['128k']], - [/^deepseek-v3(?:\.\d+)?(?:-.*)?$/, LIMITS['128k']], + [/^deepseek(?:-.*)?$/, LIMITS['128k']], // ------------------- // Moonshot / Kimi @@ -211,6 +209,12 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ // Qwen3-VL-Plus: 32K max output tokens [/^qwen3-vl-plus$/, LIMITS['32k']], + + // Deepseek-chat: 8k max tokens + [/^deepseek-chat$/, LIMITS['8k']], + + // Deepseek-reasoner: 64k max tokens + [/^deepseek-reasoner$/, LIMITS['64k']], ]; /** diff --git a/packages/core/src/ide/detect-ide.ts b/packages/core/src/ide/detect-ide.ts index 1f3e805d..c00d9a62 100644 --- a/packages/core/src/ide/detect-ide.ts +++ b/packages/core/src/ide/detect-ide.ts @@ -60,7 +60,10 @@ function verifyVSCode( if (ide.name !== IDE_DEFINITIONS.vscode.name) { return ide; } - if (ideProcessInfo.command.toLowerCase().includes('code')) { + if ( + ideProcessInfo.command && + ideProcessInfo.command.toLowerCase().includes('code') + ) { return IDE_DEFINITIONS.vscode; } return IDE_DEFINITIONS.vscodefork; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd675380..883fb114 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,6 +102,8 @@ export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-tool.js'; +export * from './tools/task.js'; +export * from './tools/todoWrite.js'; // MCP OAuth export { MCPOAuthProvider } from './mcp/oauth-provider.js'; diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index f5c7c8a0..5e1c75c5 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -181,6 +181,56 @@ describe('ChatCompressionService', () => { expect(result.newHistory).toBeNull(); }); + it('should return NOOP when contextPercentageThreshold is 0', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800); + vi.mocked(mockConfig.getChatCompression).mockReturnValue({ + contextPercentageThreshold: 0, + }); + + const mockGenerateContent = vi.fn(); + vi.mocked(mockConfig.getContentGenerator).mockReturnValue({ + generateContent: mockGenerateContent, + } as unknown as ContentGenerator); + + const result = await service.compress( + mockChat, + mockPromptId, + false, + mockModel, + mockConfig, + false, + ); + + expect(result.info).toMatchObject({ + compressionStatus: CompressionStatus.NOOP, + originalTokenCount: 0, + newTokenCount: 0, + }); + expect(mockGenerateContent).not.toHaveBeenCalled(); + expect(tokenLimit).not.toHaveBeenCalled(); + + const forcedResult = await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + expect(forcedResult.info).toMatchObject({ + compressionStatus: CompressionStatus.NOOP, + originalTokenCount: 0, + newTokenCount: 0, + }); + expect(mockGenerateContent).not.toHaveBeenCalled(); + expect(tokenLimit).not.toHaveBeenCalled(); + }); + it('should compress if over token threshold', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'msg1' }] }, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 68761fa2..f692be3e 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -86,10 +86,14 @@ export class ChatCompressionService { hasFailedCompressionAttempt: boolean, ): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> { const curatedHistory = chat.getHistory(true); + const threshold = + config.getChatCompression()?.contextPercentageThreshold ?? + COMPRESSION_TOKEN_THRESHOLD; // Regardless of `force`, don't do anything if the history is empty. if ( curatedHistory.length === 0 || + threshold <= 0 || (hasFailedCompressionAttempt && !force) ) { return { @@ -104,13 +108,8 @@ export class ChatCompressionService { const originalTokenCount = uiTelemetryService.getLastPromptTokenCount(); - const contextPercentageThreshold = - config.getChatCompression()?.contextPercentageThreshold; - // Don't compress if not forced and we are under the limit. if (!force) { - const threshold = - contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD; if (originalTokenCount < threshold * tokenLimit(model)) { return { newHistory: null, diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index 2b0468a9..5560b4fd 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -62,9 +62,10 @@ export type { SubAgentToolResultEvent, SubAgentFinishEvent, SubAgentErrorEvent, + SubAgentApprovalRequestEvent, } from './subagent-events.js'; -export { SubAgentEventEmitter } from './subagent-events.js'; +export { SubAgentEventEmitter, SubAgentEventType } from './subagent-events.js'; // Statistics and formatting export type { diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index cd24998a..3c93112d 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -8,6 +8,7 @@ import { EventEmitter } from 'events'; import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, + ToolResultDisplay, } from '../tools/tools.js'; import type { Part } from '@google/genai'; @@ -74,6 +75,7 @@ export interface SubAgentToolResultEvent { success: boolean; error?: string; responseParts?: Part[]; + resultDisplay?: ToolResultDisplay; durationMs?: number; timestamp: number; } diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index fdb15881..8dcab0de 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -29,6 +29,7 @@ import { SubagentValidator } from './validation.js'; import { SubAgentScope } from './subagent.js'; import type { Config } from '../config/config.js'; import { BuiltinAgentRegistry } from './builtin-agents.js'; +import { ToolDisplayNamesMigration } from '../tools/tool-names.js'; const QWEN_CONFIG_DIR = '.qwen'; const AGENT_CONFIG_DIR = 'agents'; @@ -632,7 +633,12 @@ export class SubagentManager { // If no exact name match, try to find by display name const displayNameMatch = allTools.find( - (tool) => tool.displayName === toolIdentifier, + (tool) => + tool.displayName === toolIdentifier || + tool.displayName === + (ToolDisplayNamesMigration[ + toolIdentifier as keyof typeof ToolDisplayNamesMigration + ] as string | undefined), ); if (displayNameMatch) { result.push(displayNameMatch.name); diff --git a/packages/core/src/telemetry/qwen-logger/event-types.ts b/packages/core/src/telemetry/qwen-logger/event-types.ts index f81fb712..ed84ba78 100644 --- a/packages/core/src/telemetry/qwen-logger/event-types.ts +++ b/packages/core/src/telemetry/qwen-logger/event-types.ts @@ -19,6 +19,21 @@ export interface RumView { name: string; } +export interface RumOS { + type?: string; + version?: string; + container?: string; + container_version?: string; +} + +export interface RumDevice { + id?: string; + name?: string; + type?: string; + brand?: string; + model?: string; +} + export interface RumEvent { timestamp?: number; event_type?: 'view' | 'action' | 'exception' | 'resource'; @@ -78,6 +93,8 @@ export interface RumPayload { user: RumUser; session: RumSession; view: RumView; + os?: RumOS; + device?: RumDevice; events: RumEvent[]; properties?: Record; _v: string; diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index 8efb2e4e..9dbaa4f9 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -13,8 +13,10 @@ import { afterEach, afterAll, } from 'vitest'; +import * as os from 'node:os'; import { QwenLogger, TEST_ONLY } from './qwen-logger.js'; import type { Config } from '../../config/config.js'; +import { AuthType } from '../../core/contentGenerator.js'; import { StartSessionEvent, EndSessionEvent, @@ -22,7 +24,7 @@ import { KittySequenceOverflowEvent, IdeConnectionType, } from '../types.js'; -import type { RumEvent } from './event-types.js'; +import type { RumEvent, RumPayload } from './event-types.js'; // Mock dependencies vi.mock('../../utils/user_id.js', () => ({ @@ -46,6 +48,7 @@ const makeFakeConfig = (overrides: Partial = {}): Config => { getCliVersion: () => '1.0.0', getProxy: () => undefined, getContentGeneratorConfig: () => ({ authType: 'test-auth' }), + getAuthType: () => AuthType.QWEN_OAUTH, getMcpServers: () => ({}), getModel: () => 'test-model', getEmbeddingModel: () => 'test-embedding', @@ -102,6 +105,24 @@ describe('QwenLogger', () => { }); }); + describe('createRumPayload', () => { + it('includes os metadata in payload', async () => { + const logger = QwenLogger.getInstance(mockConfig)!; + const payload = await ( + logger as unknown as { + createRumPayload(): Promise; + } + ).createRumPayload(); + + expect(payload.os).toEqual( + expect.objectContaining({ + type: os.platform(), + version: os.release(), + }), + ); + }); + }); + describe('event queue management', () => { it('should handle event overflow gracefully', () => { const debugConfig = makeFakeConfig({ getDebugMode: () => true }); diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 09463267..5f9a2a51 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -6,6 +6,7 @@ import { Buffer } from 'buffer'; import * as https from 'https'; +import * as os from 'node:os'; import { HttpsProxyAgent } from 'https-proxy-agent'; import type { @@ -45,10 +46,10 @@ import type { RumResourceEvent, RumExceptionEvent, RumPayload, + RumOS, } from './event-types.js'; import type { Config } from '../../config/config.js'; import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; -import { type HttpError, retryWithBackoff } from '../../utils/retry.js'; import { InstallationManager } from '../../utils/installationManager.js'; import { FixedDeque } from 'mnemonist'; import { AuthType } from '../../core/contentGenerator.js'; @@ -215,9 +216,17 @@ export class QwenLogger { return this.createRumEvent('exception', type, name, properties); } + private getOsMetadata(): RumOS { + return { + type: os.platform(), + version: os.release(), + }; + } + async createRumPayload(): Promise { const authType = this.config?.getAuthType(); const version = this.config?.getCliVersion() || 'unknown'; + const osMetadata = this.getOsMetadata(); return { app: { @@ -236,6 +245,7 @@ export class QwenLogger { id: this.sessionId, name: 'qwen-code-cli', }, + os: osMetadata, events: this.events.toArray() as RumEvent[], properties: { @@ -288,8 +298,8 @@ export class QwenLogger { const rumPayload = await this.createRumPayload(); // Override events with the ones we're sending rumPayload.events = eventsToSend; - const flushFn = () => - new Promise((resolve, reject) => { + try { + await new Promise((resolve, reject) => { const body = safeJsonStringify(rumPayload); const options = { hostname: USAGE_STATS_HOSTNAME, @@ -311,10 +321,9 @@ export class QwenLogger { res.statusCode && (res.statusCode < 200 || res.statusCode >= 300) ) { - const err: HttpError = new Error( + const err = new Error( `Request failed with status ${res.statusCode}`, ); - err.status = res.statusCode; res.resume(); return reject(err); } @@ -326,26 +335,11 @@ export class QwenLogger { req.end(body); }); - try { - await retryWithBackoff(flushFn, { - maxAttempts: 3, - initialDelayMs: 200, - shouldRetryOnError: (err: unknown) => { - if (!(err instanceof Error)) return false; - const status = (err as HttpError).status as number | undefined; - // If status is not available, it's likely a network error - if (status === undefined) return true; - - // Retry on 429 (Too many Requests) and 5xx server errors. - return status === 429 || (status >= 500 && status < 600); - }, - }); - this.lastFlushTime = Date.now(); return {}; } catch (error) { if (this.config?.getDebugMode()) { - console.error('RUM flush failed after multiple retries.', error); + console.error('RUM flush failed.', error); } // Re-queue failed events for retry diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index b695087a..9e41b938 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -425,7 +425,9 @@ describe('EditTool', () => { const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toMatch(/Successfully modified file/); + expect(result.llmContent).toMatch( + /Showing lines \d+-\d+ of \d+ from the edited file:/, + ); expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); const display = result.returnDisplay as FileDiff; expect(display.fileDiff).toMatch(initialContent); @@ -450,6 +452,9 @@ describe('EditTool', () => { const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch(/Created new file/); + expect(result.llmContent).toMatch( + /Showing lines \d+-\d+ of \d+ from the edited file:/, + ); expect(fs.existsSync(newFilePath)).toBe(true); expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent); @@ -485,7 +490,7 @@ describe('EditTool', () => { ); }); - it('should return error if multiple occurrences of old_string are found', async () => { + it('should return error if multiple occurrences of old_string are found and replace_all is false', async () => { fs.writeFileSync(filePath, 'multiple old old strings', 'utf8'); const params: EditToolParams = { file_path: filePath, @@ -494,27 +499,27 @@ describe('EditTool', () => { }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toMatch( - /Expected 1 occurrence but found 2 for old_string in file/, - ); + expect(result.llmContent).toMatch(/replace_all was not enabled/); expect(result.returnDisplay).toMatch( - /Failed to edit, expected 1 occurrence but found 2/, + /Failed to edit because the text matches multiple locations/, ); }); - it('should successfully replace multiple occurrences when expected_replacements specified', async () => { + it('should successfully replace multiple occurrences when replace_all is true', async () => { fs.writeFileSync(filePath, 'old text\nold text\nold text', 'utf8'); const params: EditToolParams = { file_path: filePath, old_string: 'old', new_string: 'new', - expected_replacements: 3, + replace_all: true, }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toMatch(/Successfully modified file/); + expect(result.llmContent).toMatch( + /Showing lines \d+-\d+ of \d+ from the edited file/, + ); expect(fs.readFileSync(filePath, 'utf8')).toBe( 'new text\nnew text\nnew text', ); @@ -535,24 +540,6 @@ describe('EditTool', () => { }); }); - it('should return error if expected_replacements does not match actual occurrences', async () => { - fs.writeFileSync(filePath, 'old text old text', 'utf8'); - const params: EditToolParams = { - file_path: filePath, - old_string: 'old', - new_string: 'new', - expected_replacements: 3, // Expecting 3 but only 2 exist - }; - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - expect(result.llmContent).toMatch( - /Expected 3 occurrences but found 2 for old_string in file/, - ); - expect(result.returnDisplay).toMatch( - /Failed to edit, expected 3 occurrences but found 2/, - ); - }); - it('should return error if trying to create a file that already exists (empty old_string)', async () => { fs.writeFileSync(filePath, 'Existing content', 'utf8'); const params: EditToolParams = { @@ -568,38 +555,6 @@ describe('EditTool', () => { ); }); - it('should include modification message when proposed content is modified', async () => { - const initialContent = 'Line 1\nold line\nLine 3\nLine 4\nLine 5\n'; - fs.writeFileSync(filePath, initialContent, 'utf8'); - const params: EditToolParams = { - file_path: filePath, - old_string: 'old', - new_string: 'new', - modified_by_user: true, - ai_proposed_content: 'Line 1\nAI line\nLine 3\nLine 4\nLine 5\n', - }; - - (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( - ApprovalMode.AUTO_EDIT, - ); - const invocation = tool.build(params); - const result = await invocation.execute(new AbortController().signal); - - expect(result.llmContent).toMatch( - /User modified the `new_string` content/, - ); - expect((result.returnDisplay as FileDiff).diffStat).toStrictEqual({ - model_added_lines: 1, - model_removed_lines: 1, - model_added_chars: 7, - model_removed_chars: 8, - user_added_lines: 1, - user_removed_lines: 1, - user_added_chars: 8, - user_removed_chars: 7, - }); - }); - it('should not include modification message when proposed content is not modified', async () => { const initialContent = 'This is some old text.'; fs.writeFileSync(filePath, initialContent, 'utf8'); @@ -723,13 +678,12 @@ describe('EditTool', () => { expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_OCCURRENCE_FOUND); }); - it('should return EXPECTED_OCCURRENCE_MISMATCH error', async () => { + it('should return EXPECTED_OCCURRENCE_MISMATCH error when replace_all is false and text is not unique', async () => { fs.writeFileSync(filePath, 'one one two', 'utf8'); const params: EditToolParams = { file_path: filePath, old_string: 'one', new_string: 'new', - expected_replacements: 3, }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index bd68b105..ec257290 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -22,7 +22,7 @@ import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; import { ReadFileTool } from './read-file.js'; -import { ToolNames } from './tool-names.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; import { FileOperation } from '../telemetry/metrics.js'; @@ -34,6 +34,12 @@ import type { } from './modifiable-tool.js'; import { IdeClient } from '../ide/ide-client.js'; import { safeLiteralReplace } from '../utils/textUtils.js'; +import { + countOccurrences, + extractEditSnippet, + maybeAugmentOldStringForDeletion, + normalizeEditStrings, +} from '../utils/editHelper.js'; export function applyReplacement( currentContent: string | null, @@ -77,10 +83,9 @@ export interface EditToolParams { new_string: string; /** - * Number of replacements expected. Defaults to 1 if not specified. - * Use when you want to replace multiple occurrences. + * Replace every occurrence of old_string instead of requiring a unique match. */ - expected_replacements?: number; + replace_all?: boolean; /** * Whether the edit was modified manually by the user. @@ -118,12 +123,12 @@ class EditToolInvocation implements ToolInvocation { * @throws File system errors if reading the file fails unexpectedly (e.g., permissions) */ private async calculateEdit(params: EditToolParams): Promise { - const expectedReplacements = params.expected_replacements ?? 1; + const replaceAll = params.replace_all ?? false; let currentContent: string | null = null; let fileExists = false; let isNewFile = false; - const finalNewString = params.new_string; - const finalOldString = params.old_string; + let finalNewString = params.new_string; + let finalOldString = params.old_string; let occurrences = 0; let error: | { display: string; raw: string; type: ToolErrorType } @@ -144,7 +149,15 @@ class EditToolInvocation implements ToolInvocation { fileExists = false; } - if (params.old_string === '' && !fileExists) { + const normalizedStrings = normalizeEditStrings( + currentContent, + finalOldString, + finalNewString, + ); + finalOldString = normalizedStrings.oldString; + finalNewString = normalizedStrings.newString; + + if (finalOldString === '' && !fileExists) { // Creating a new file isNewFile = true; } else if (!fileExists) { @@ -155,7 +168,13 @@ class EditToolInvocation implements ToolInvocation { type: ToolErrorType.FILE_NOT_FOUND, }; } else if (currentContent !== null) { - occurrences = this.countOccurrences(currentContent, params.old_string); + finalOldString = maybeAugmentOldStringForDeletion( + currentContent, + finalOldString, + finalNewString, + ); + + occurrences = countOccurrences(currentContent, finalOldString); if (params.old_string === '') { // Error: Trying to create a file that already exists error = { @@ -169,13 +188,10 @@ class EditToolInvocation implements ToolInvocation { raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${ReadFileTool.Name} tool to verify.`, type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND, }; - } else if (occurrences !== expectedReplacements) { - const occurrenceTerm = - expectedReplacements === 1 ? 'occurrence' : 'occurrences'; - + } else if (!replaceAll && occurrences > 1) { error = { - display: `Failed to edit, expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences}.`, - raw: `Failed to edit, Expected ${expectedReplacements} ${occurrenceTerm} but found ${occurrences} for old_string in file: ${params.file_path}`, + display: `Failed to edit because the text matches multiple locations. Provide more context or set replace_all to true.`, + raw: `Failed to edit. Found ${occurrences} occurrences for old_string in ${params.file_path} but replace_all was not enabled.`, type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, }; } else if (finalOldString === finalNewString) { @@ -221,22 +237,6 @@ class EditToolInvocation implements ToolInvocation { }; } - /** - * Counts occurrences of a substring in a string - */ - private countOccurrences(str: string, substr: string): number { - if (substr === '') { - return 0; - } - let count = 0; - let pos = str.indexOf(substr); - while (pos !== -1) { - count++; - pos = str.indexOf(substr, pos + substr.length); // Start search after the current match - } - return count; - } - /** * Handles the confirmation prompt for the Edit tool in the CLI. * It needs to calculate the diff to show the user. @@ -422,12 +422,16 @@ class EditToolInvocation implements ToolInvocation { const llmSuccessMessageParts = [ editData.isNewFile ? `Created new file: ${this.params.file_path} with provided content.` - : `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`, + : `The file: ${this.params.file_path} has been updated.`, ]; - if (this.params.modified_by_user) { - llmSuccessMessageParts.push( - `User modified the \`new_string\` content to be: ${this.params.new_string}.`, - ); + + const snippetResult = extractEditSnippet( + editData.currentContent, + editData.newContent, + ); + if (snippetResult) { + const snippetText = `Showing lines ${snippetResult.startLine}-${snippetResult.endLine} of ${snippetResult.totalLines} from the edited file:\n\n---\n\n${snippetResult.content}`; + llmSuccessMessageParts.push(snippetText); } return { @@ -469,8 +473,8 @@ export class EditTool constructor(private readonly config: Config) { super( EditTool.Name, - 'Edit', - `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement. + ToolDisplayNames.EDIT, + `Replaces text within a file. By default, replaces a single occurrence. Set \`replace_all\` to true when you intend to modify every instance of \`old_string\`. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement. The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. @@ -480,7 +484,7 @@ Expectation for required parameters: 3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic. 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. -**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, +**Multiple replacements:** Set \`replace_all\` to true when you want to replace every occurrence that matches \`old_string\`.`, Kind.Edit, { properties: { @@ -491,7 +495,7 @@ Expectation for required parameters: }, old_string: { description: - 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', + 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', type: 'string', }, new_string: { @@ -499,11 +503,10 @@ Expectation for required parameters: 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', type: 'string', }, - expected_replacements: { - type: 'number', + replace_all: { + type: 'boolean', description: - 'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', - minimum: 1, + 'Replace all occurrences of old_string (default false).', }, }, required: ['file_path', 'old_string', 'new_string'], diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index d39b8bbe..6ff7d176 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -14,6 +14,7 @@ import { import type { FunctionDeclaration } from '@google/genai'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; export interface ExitPlanModeParams { plan: string; @@ -152,12 +153,12 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< ExitPlanModeParams, ToolResult > { - static readonly Name: string = exitPlanModeToolSchemaData.name!; + static readonly Name: string = ToolNames.EXIT_PLAN_MODE; constructor(private readonly config: Config) { super( ExitPlanModeTool.Name, - 'ExitPlanMode', + ToolDisplayNames.EXIT_PLAN_MODE, exitPlanModeToolDescription, Kind.Think, exitPlanModeToolSchemaData.parametersJsonSchema as Record< diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index 164eb7e0..3729c251 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -37,6 +37,7 @@ describe('GlobTool', () => { getFileExclusions: () => ({ getGlobExcludes: () => [], }), + getTruncateToolOutputLines: () => 1000, } as unknown as Config; beforeEach(async () => { @@ -88,17 +89,6 @@ describe('GlobTool', () => { expect(result.returnDisplay).toBe('Found 2 matching file(s)'); }); - it('should find files case-sensitively when case_sensitive is true', async () => { - const params: GlobToolParams = { pattern: '*.txt', case_sensitive: true }; - const invocation = globTool.build(params); - const result = await invocation.execute(abortSignal); - expect(result.llmContent).toContain('Found 1 file(s)'); - expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); - expect(result.llmContent).not.toContain( - path.join(tempRootDir, 'FileB.TXT'), - ); - }); - it('should find files case-insensitively by default (pattern: *.TXT)', async () => { const params: GlobToolParams = { pattern: '*.TXT' }; const invocation = globTool.build(params); @@ -108,18 +98,6 @@ describe('GlobTool', () => { expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); }); - it('should find files case-insensitively when case_sensitive is false (pattern: *.TXT)', async () => { - const params: GlobToolParams = { - pattern: '*.TXT', - case_sensitive: false, - }; - const invocation = globTool.build(params); - const result = await invocation.execute(abortSignal); - expect(result.llmContent).toContain('Found 2 file(s)'); - expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); - expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); - }); - it('should find files using a pattern that includes a subdirectory', async () => { const params: GlobToolParams = { pattern: 'sub/*.md' }; const invocation = globTool.build(params); @@ -207,7 +185,7 @@ describe('GlobTool', () => { const filesListed = llmContent .trim() .split(/\r?\n/) - .slice(1) + .slice(2) .map((line) => line.trim()) .filter(Boolean); @@ -220,14 +198,13 @@ describe('GlobTool', () => { ); }); - it('should return a PATH_NOT_IN_WORKSPACE error if path is outside workspace', async () => { + it('should return error if path is outside workspace', async () => { // Bypassing validation to test execute method directly vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null); const params: GlobToolParams = { pattern: '*.txt', path: '/etc' }; const invocation = globTool.build(params); const result = await invocation.execute(abortSignal); - expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE); - expect(result.returnDisplay).toBe('Path is not within workspace'); + expect(result.returnDisplay).toBe('Error: Path is not within workspace'); }); it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => { @@ -255,15 +232,6 @@ describe('GlobTool', () => { expect(globTool.validateToolParams(params)).toBeNull(); }); - it('should return null for valid parameters (pattern, path, and case_sensitive)', () => { - const params: GlobToolParams = { - pattern: '*.js', - path: 'sub', - case_sensitive: true, - }; - expect(globTool.validateToolParams(params)).toBeNull(); - }); - it('should return error if pattern is missing (schema validation)', () => { // Need to correctly define this as an object without pattern const params = { path: '.' }; @@ -297,16 +265,6 @@ describe('GlobTool', () => { ); }); - it('should return error if case_sensitive is provided but is not a boolean', () => { - const params = { - pattern: '*.ts', - case_sensitive: 'true', - } as unknown as GlobToolParams; // Force incorrect type - expect(globTool.validateToolParams(params)).toBe( - 'params/case_sensitive must be boolean', - ); - }); - it("should return error if search path resolves outside the tool's root directory", () => { // Create a globTool instance specifically for this test, with a deeper root tempRootDir = path.join(tempRootDir, 'sub'); @@ -319,7 +277,7 @@ describe('GlobTool', () => { path: '../../../../../../../../../../tmp', // Definitely outside }; expect(specificGlobTool.validateToolParams(paramsOutside)).toContain( - 'resolves outside the allowed workspace directories', + 'Path is not within workspace', ); }); @@ -329,14 +287,14 @@ describe('GlobTool', () => { path: 'nonexistent_subdir', }; expect(globTool.validateToolParams(params)).toContain( - 'Search path does not exist', + 'Path does not exist', ); }); it('should return error if specified search path is a file, not a directory', async () => { const params: GlobToolParams = { pattern: '*.txt', path: 'fileA.txt' }; expect(globTool.validateToolParams(params)).toContain( - 'Search path is not a directory', + 'Path is not a directory', ); }); }); @@ -348,20 +306,10 @@ describe('GlobTool', () => { expect(globTool.validateToolParams(validPath)).toBeNull(); expect(globTool.validateToolParams(invalidPath)).toContain( - 'resolves outside the allowed workspace directories', + 'Path is not within workspace', ); }); - it('should provide clear error messages when path is outside workspace', () => { - const invalidPath = { pattern: '*.ts', path: '/etc' }; - const error = globTool.validateToolParams(invalidPath); - - expect(error).toContain( - 'resolves outside the allowed workspace directories', - ); - expect(error).toContain(tempRootDir); - }); - it('should work with paths in workspace subdirectories', async () => { const params: GlobToolParams = { pattern: '*.md', path: 'sub' }; const invocation = globTool.build(params); @@ -417,47 +365,123 @@ describe('GlobTool', () => { expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, b.notignored.txt expect(result.llmContent).not.toContain('a.qwenignored.txt'); }); + }); - it('should not respect .gitignore when respect_git_ignore is false', async () => { - await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.ignored.txt'); - await fs.writeFile( - path.join(tempRootDir, 'a.ignored.txt'), - 'ignored content', - ); + describe('file count truncation', () => { + it('should truncate results when more than 100 files are found', async () => { + // Create 150 test files + for (let i = 1; i <= 150; i++) { + await fs.writeFile( + path.join(tempRootDir, `file${i}.trunctest`), + `content${i}`, + ); + } - const params: GlobToolParams = { - pattern: '*.txt', - respect_git_ignore: false, - }; + const params: GlobToolParams = { pattern: '*.trunctest' }; const invocation = globTool.build(params); const result = await invocation.execute(abortSignal); + const llmContent = partListUnionToString(result.llmContent); - expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.ignored.txt - expect(result.llmContent).toContain('a.ignored.txt'); + // Should report all 150 files found + expect(llmContent).toContain('Found 150 file(s)'); + + // Should include truncation notice + expect(llmContent).toContain('[50 files truncated] ...'); + + // Count the number of .trunctest files mentioned in the output + const fileMatches = llmContent.match(/file\d+\.trunctest/g); + expect(fileMatches).toBeDefined(); + expect(fileMatches?.length).toBe(100); + + // returnDisplay should indicate truncation + expect(result.returnDisplay).toBe( + 'Found 150 matching file(s) (truncated)', + ); }); - it('should not respect .qwenignore when respect_qwen_ignore is false', async () => { - await fs.writeFile( - path.join(tempRootDir, '.qwenignore'), - '*.qwenignored.txt', - ); - await fs.writeFile( - path.join(tempRootDir, 'a.qwenignored.txt'), - 'ignored content', - ); + it('should not truncate when exactly 100 files are found', async () => { + // Create exactly 100 test files + for (let i = 1; i <= 100; i++) { + await fs.writeFile( + path.join(tempRootDir, `exact${i}.trunctest`), + `content${i}`, + ); + } - // Recreate the tool to pick up the new .qwenignore file - globTool = new GlobTool(mockConfig); - - const params: GlobToolParams = { - pattern: '*.txt', - respect_qwen_ignore: false, - }; + const params: GlobToolParams = { pattern: '*.trunctest' }; const invocation = globTool.build(params); const result = await invocation.execute(abortSignal); - expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.qwenignored.txt - expect(result.llmContent).toContain('a.qwenignored.txt'); + // Should report all 100 files found + expect(result.llmContent).toContain('Found 100 file(s)'); + + // Should NOT include truncation notice + expect(result.llmContent).not.toContain('truncated'); + + // Should show all 100 files + expect(result.llmContent).toContain('exact1.trunctest'); + expect(result.llmContent).toContain('exact100.trunctest'); + + // returnDisplay should NOT indicate truncation + expect(result.returnDisplay).toBe('Found 100 matching file(s)'); + }); + + it('should not truncate when fewer than 100 files are found', async () => { + // Create 50 test files + for (let i = 1; i <= 50; i++) { + await fs.writeFile( + path.join(tempRootDir, `small${i}.trunctest`), + `content${i}`, + ); + } + + const params: GlobToolParams = { pattern: '*.trunctest' }; + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); + + // Should report all 50 files found + expect(result.llmContent).toContain('Found 50 file(s)'); + + // Should NOT include truncation notice + expect(result.llmContent).not.toContain('truncated'); + + // returnDisplay should NOT indicate truncation + expect(result.returnDisplay).toBe('Found 50 matching file(s)'); + }); + + it('should use correct singular/plural in truncation message for 1 file truncated', async () => { + // Create 101 test files (will truncate 1 file) + for (let i = 1; i <= 101; i++) { + await fs.writeFile( + path.join(tempRootDir, `singular${i}.trunctest`), + `content${i}`, + ); + } + + const params: GlobToolParams = { pattern: '*.trunctest' }; + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); + + // Should use singular "file" for 1 truncated file + expect(result.llmContent).toContain('[1 file truncated] ...'); + expect(result.llmContent).not.toContain('[1 files truncated]'); + }); + + it('should use correct plural in truncation message for multiple files truncated', async () => { + // Create 105 test files (will truncate 5 files) + for (let i = 1; i <= 105; i++) { + await fs.writeFile( + path.join(tempRootDir, `plural${i}.trunctest`), + `content${i}`, + ); + } + + const params: GlobToolParams = { pattern: '*.trunctest' }; + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); + + // Should use plural "files" for multiple truncated files + expect(result.llmContent).toContain('[5 files truncated] ...'); }); }); }); diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 4826c859..29b6cf86 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -9,11 +9,18 @@ import path from 'node:path'; import { glob, escape } from 'glob'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import { ToolNames } from './tool-names.js'; -import { shortenPath, makeRelative } from '../utils/paths.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import { resolveAndValidatePath } from '../utils/paths.js'; import { type Config } from '../config/config.js'; -import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; +import { + DEFAULT_FILE_FILTERING_OPTIONS, + type FileFilteringOptions, +} from '../config/constants.js'; import { ToolErrorType } from './tool-error.js'; +import { getErrorMessage } from '../utils/errors.js'; +import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; + +const MAX_FILE_COUNT = 100; // Subset of 'Path' interface provided by 'glob' that we can implement for testing export interface GlobPath { @@ -64,118 +71,68 @@ export interface GlobToolParams { * The directory to search in (optional, defaults to current directory) */ path?: string; - - /** - * Whether the search should be case-sensitive (optional, defaults to false) - */ - case_sensitive?: boolean; - - /** - * Whether to respect .gitignore patterns (optional, defaults to true) - */ - respect_git_ignore?: boolean; - - /** - * Whether to respect .qwenignore patterns (optional, defaults to true) - */ - respect_qwen_ignore?: boolean; } class GlobToolInvocation extends BaseToolInvocation< GlobToolParams, ToolResult > { + private fileService: FileDiscoveryService; + constructor( private config: Config, params: GlobToolParams, ) { super(params); + this.fileService = config.getFileService(); } getDescription(): string { let description = `'${this.params.pattern}'`; if (this.params.path) { - const searchDir = path.resolve( - this.config.getTargetDir(), - this.params.path || '.', - ); - const relativePath = makeRelative(searchDir, this.config.getTargetDir()); - description += ` within ${shortenPath(relativePath)}`; + description += ` in path '${this.params.path}'`; } + return description; } async execute(signal: AbortSignal): Promise { try { - const workspaceContext = this.config.getWorkspaceContext(); - const workspaceDirectories = workspaceContext.getDirectories(); + // Default to target directory if no path is provided + const searchDirAbs = resolveAndValidatePath( + this.config, + this.params.path, + ); + const searchLocationDescription = this.params.path + ? `within ${searchDirAbs}` + : `in the workspace directory`; - // If a specific path is provided, resolve it and check if it's within workspace - let searchDirectories: readonly string[]; - if (this.params.path) { - const searchDirAbsolute = path.resolve( - this.config.getTargetDir(), - this.params.path, - ); - if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) { - const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`; - return { - llmContent: rawError, - returnDisplay: `Path is not within workspace`, - error: { - message: rawError, - type: ToolErrorType.PATH_NOT_IN_WORKSPACE, - }, - }; - } - searchDirectories = [searchDirAbsolute]; - } else { - // Search across all workspace directories - searchDirectories = workspaceDirectories; + // Collect entries from the search directory + let pattern = this.params.pattern; + const fullPath = path.join(searchDirAbs, pattern); + if (fs.existsSync(fullPath)) { + pattern = escape(pattern); } - // Get centralized file discovery service - const fileDiscovery = this.config.getFileService(); - - // Collect entries from all search directories - const allEntries: GlobPath[] = []; - for (const searchDir of searchDirectories) { - let pattern = this.params.pattern; - const fullPath = path.join(searchDir, pattern); - if (fs.existsSync(fullPath)) { - pattern = escape(pattern); - } - - const entries = (await glob(pattern, { - cwd: searchDir, - withFileTypes: true, - nodir: true, - stat: true, - nocase: !this.params.case_sensitive, - dot: true, - ignore: this.config.getFileExclusions().getGlobExcludes(), - follow: false, - signal, - })) as GlobPath[]; - - allEntries.push(...entries); - } + const allEntries = (await glob(pattern, { + cwd: searchDirAbs, + withFileTypes: true, + nodir: true, + stat: true, + nocase: true, + dot: true, + follow: false, + signal, + })) as GlobPath[]; const relativePaths = allEntries.map((p) => path.relative(this.config.getTargetDir(), p.fullpath()), ); - const { filteredPaths, gitIgnoredCount, qwenIgnoredCount } = - fileDiscovery.filterFilesWithReport(relativePaths, { - respectGitIgnore: - this.params?.respect_git_ignore ?? - this.config.getFileFilteringOptions().respectGitIgnore ?? - DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, - respectQwenIgnore: - this.params?.respect_qwen_ignore ?? - this.config.getFileFilteringOptions().respectQwenIgnore ?? - DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore, - }); + const { filteredPaths } = this.fileService.filterFilesWithReport( + relativePaths, + this.getFileFilteringOptions(), + ); const filteredAbsolutePaths = new Set( filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)), @@ -186,20 +143,8 @@ class GlobToolInvocation extends BaseToolInvocation< ); if (!filteredEntries || filteredEntries.length === 0) { - let message = `No files found matching pattern "${this.params.pattern}"`; - if (searchDirectories.length === 1) { - message += ` within ${searchDirectories[0]}`; - } else { - message += ` within ${searchDirectories.length} workspace directories`; - } - if (gitIgnoredCount > 0) { - message += ` (${gitIgnoredCount} files were git-ignored)`; - } - if (qwenIgnoredCount > 0) { - message += ` (${qwenIgnoredCount} files were qwen-ignored)`; - } return { - llmContent: message, + llmContent: `No files found matching pattern "${this.params.pattern}" ${searchLocationDescription}`, returnDisplay: `No files found`, }; } @@ -215,29 +160,36 @@ class GlobToolInvocation extends BaseToolInvocation< oneDayInMs, ); - const sortedAbsolutePaths = sortedEntries.map((entry) => + const totalFileCount = sortedEntries.length; + const fileLimit = Math.min( + MAX_FILE_COUNT, + this.config.getTruncateToolOutputLines(), + ); + const truncated = totalFileCount > fileLimit; + + // Limit to fileLimit if needed + const entriesToShow = truncated + ? sortedEntries.slice(0, fileLimit) + : sortedEntries; + + const sortedAbsolutePaths = entriesToShow.map((entry) => entry.fullpath(), ); const fileListDescription = sortedAbsolutePaths.join('\n'); - const fileCount = sortedAbsolutePaths.length; - let resultMessage = `Found ${fileCount} file(s) matching "${this.params.pattern}"`; - if (searchDirectories.length === 1) { - resultMessage += ` within ${searchDirectories[0]}`; - } else { - resultMessage += ` across ${searchDirectories.length} workspace directories`; + let resultMessage = `Found ${totalFileCount} file(s) matching "${this.params.pattern}" ${searchLocationDescription}`; + resultMessage += `, sorted by modification time (newest first):\n---\n${fileListDescription}`; + + // Add truncation notice if needed + if (truncated) { + const omittedFiles = totalFileCount - fileLimit; + const fileTerm = omittedFiles === 1 ? 'file' : 'files'; + resultMessage += `\n---\n[${omittedFiles} ${fileTerm} truncated] ...`; } - if (gitIgnoredCount > 0) { - resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`; - } - if (qwenIgnoredCount > 0) { - resultMessage += ` (${qwenIgnoredCount} additional files were qwen-ignored)`; - } - resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`; return { llmContent: resultMessage, - returnDisplay: `Found ${fileCount} matching file(s)`, + returnDisplay: `Found ${totalFileCount} matching file(s)${truncated ? ' (truncated)' : ''}`, }; } catch (error) { const errorMessage = @@ -246,7 +198,7 @@ class GlobToolInvocation extends BaseToolInvocation< const rawError = `Error during glob search operation: ${errorMessage}`; return { llmContent: rawError, - returnDisplay: `Error: An unexpected error occurred.`, + returnDisplay: `Error: ${errorMessage || 'An unexpected error occurred.'}`, error: { message: rawError, type: ToolErrorType.GLOB_EXECUTION_ERROR, @@ -254,6 +206,18 @@ class GlobToolInvocation extends BaseToolInvocation< }; } } + + private getFileFilteringOptions(): FileFilteringOptions { + const options = this.config.getFileFilteringOptions?.(); + return { + respectGitIgnore: + options?.respectGitIgnore ?? + DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore, + respectQwenIgnore: + options?.respectQwenIgnore ?? + DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore, + }; + } } /** @@ -265,36 +229,20 @@ export class GlobTool extends BaseDeclarativeTool { constructor(private config: Config) { super( GlobTool.Name, - 'FindFiles', - 'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.', + ToolDisplayNames.GLOB, + 'Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.', Kind.Search, { properties: { pattern: { - description: - "The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').", + description: 'The glob pattern to match files against', type: 'string', }, path: { description: - 'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.', + 'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.', type: 'string', }, - case_sensitive: { - description: - 'Optional: Whether the search should be case-sensitive. Defaults to false.', - type: 'boolean', - }, - respect_git_ignore: { - description: - 'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.', - type: 'boolean', - }, - respect_qwen_ignore: { - description: - 'Optional: Whether to respect .qwenignore patterns when finding files. Defaults to true.', - type: 'boolean', - }, }, required: ['pattern'], type: 'object', @@ -308,29 +256,6 @@ export class GlobTool extends BaseDeclarativeTool { protected override validateToolParamValues( params: GlobToolParams, ): string | null { - const searchDirAbsolute = path.resolve( - this.config.getTargetDir(), - params.path || '.', - ); - - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) { - const directories = workspaceContext.getDirectories(); - return `Search path ("${searchDirAbsolute}") resolves outside the allowed workspace directories: ${directories.join(', ')}`; - } - - const targetDir = searchDirAbsolute || this.config.getTargetDir(); - try { - if (!fs.existsSync(targetDir)) { - return `Search path does not exist ${targetDir}`; - } - if (!fs.statSync(targetDir).isDirectory()) { - return `Search path is not a directory: ${targetDir}`; - } - } catch (e: unknown) { - return `Error accessing search path: ${e}`; - } - if ( !params.pattern || typeof params.pattern !== 'string' || @@ -339,6 +264,15 @@ export class GlobTool extends BaseDeclarativeTool { return "The 'pattern' parameter cannot be empty."; } + // Only validate path if one is provided + if (params.path) { + try { + resolveAndValidatePath(this.config, params.path); + } catch (error) { + return getErrorMessage(error); + } + } + return null; } diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index f0707908..d613ff03 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -43,6 +43,8 @@ describe('GrepTool', () => { getFileExclusions: () => ({ getGlobExcludes: () => [], }), + getTruncateToolOutputThreshold: () => 25000, + getTruncateToolOutputLines: () => 1000, } as unknown as Config; beforeEach(async () => { @@ -84,11 +86,11 @@ describe('GrepTool', () => { expect(grepTool.validateToolParams(params)).toBeNull(); }); - it('should return null for valid params (pattern, path, and include)', () => { + it('should return null for valid params (pattern, path, and glob)', () => { const params: GrepToolParams = { pattern: 'hello', path: '.', - include: '*.txt', + glob: '*.txt', }; expect(grepTool.validateToolParams(params)).toBeNull(); }); @@ -111,7 +113,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'hello', path: 'nonexistent' }; // Check for the core error message, as the full path might vary expect(grepTool.validateToolParams(params)).toContain( - 'Failed to access path stats for', + 'Path does not exist:', ); expect(grepTool.validateToolParams(params)).toContain('nonexistent'); }); @@ -155,8 +157,8 @@ describe('GrepTool', () => { expect(result.returnDisplay).toBe('Found 1 match'); }); - it('should find matches with an include glob', async () => { - const params: GrepToolParams = { pattern: 'hello', include: '*.js' }; + it('should find matches with a glob filter', async () => { + const params: GrepToolParams = { pattern: 'hello', glob: '*.js' }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( @@ -169,7 +171,7 @@ describe('GrepTool', () => { expect(result.returnDisplay).toBe('Found 1 match'); }); - it('should find matches with an include glob and path', async () => { + it('should find matches with a glob filter and path', async () => { await fs.writeFile( path.join(tempRootDir, 'sub', 'another.js'), 'const greeting = "hello";', @@ -177,7 +179,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'hello', path: 'sub', - include: '*.js', + glob: '*.js', }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -244,59 +246,23 @@ describe('GrepTool', () => { describe('multi-directory workspace', () => { it('should search across all workspace directories when no path is specified', async () => { - // Create additional directory with test files - const secondDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'grep-tool-second-'), - ); - await fs.writeFile( - path.join(secondDir, 'other.txt'), - 'hello from second directory\nworld in second', - ); - await fs.writeFile( - path.join(secondDir, 'another.js'), - 'function world() { return "test"; }', - ); - - // Create a mock config with multiple directories - const multiDirConfig = { - getTargetDir: () => tempRootDir, - getWorkspaceContext: () => - createMockWorkspaceContext(tempRootDir, [secondDir]), - getFileExclusions: () => ({ - getGlobExcludes: () => [], - }), - } as unknown as Config; - - const multiDirGrepTool = new GrepTool(multiDirConfig); + // The new implementation searches only in the target directory (first workspace directory) + // when no path is specified, not across all workspace directories const params: GrepToolParams = { pattern: 'world' }; - const invocation = multiDirGrepTool.build(params); + const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); - // Should find matches in both directories + // Should find matches in the target directory only expect(result.llmContent).toContain( - 'Found 5 matches for pattern "world"', + 'Found 3 matches for pattern "world" in the workspace directory', ); - // Matches from first directory + // Matches from target directory expect(result.llmContent).toContain('fileA.txt'); expect(result.llmContent).toContain('L1: hello world'); expect(result.llmContent).toContain('L2: second line with world'); expect(result.llmContent).toContain('fileC.txt'); expect(result.llmContent).toContain('L1: another world in sub dir'); - - // Matches from second directory (with directory name prefix) - const secondDirName = path.basename(secondDir); - expect(result.llmContent).toContain( - `File: ${path.join(secondDirName, 'other.txt')}`, - ); - expect(result.llmContent).toContain('L2: world in second'); - expect(result.llmContent).toContain( - `File: ${path.join(secondDirName, 'another.js')}`, - ); - expect(result.llmContent).toContain('L1: function world()'); - - // Clean up - await fs.rm(secondDir, { recursive: true, force: true }); }); it('should search only specified path within workspace directories', async () => { @@ -318,6 +284,8 @@ describe('GrepTool', () => { getFileExclusions: () => ({ getGlobExcludes: () => [], }), + getTruncateToolOutputThreshold: () => 25000, + getTruncateToolOutputLines: () => 1000, } as unknown as Config; const multiDirGrepTool = new GrepTool(multiDirConfig); @@ -346,16 +314,18 @@ describe('GrepTool', () => { it('should generate correct description with pattern only', () => { const params: GrepToolParams = { pattern: 'testPattern' }; const invocation = grepTool.build(params); - expect(invocation.getDescription()).toBe("'testPattern'"); + expect(invocation.getDescription()).toBe("'testPattern' in path './'"); }); - it('should generate correct description with pattern and include', () => { + it('should generate correct description with pattern and glob', () => { const params: GrepToolParams = { pattern: 'testPattern', - include: '*.ts', + glob: '*.ts', }; const invocation = grepTool.build(params); - expect(invocation.getDescription()).toBe("'testPattern' in *.ts"); + expect(invocation.getDescription()).toBe( + "'testPattern' in path './' (filter: '*.ts')", + ); }); it('should generate correct description with pattern and path', async () => { @@ -366,49 +336,37 @@ describe('GrepTool', () => { path: path.join('src', 'app'), }; const invocation = grepTool.build(params); - // The path will be relative to the tempRootDir, so we check for containment. - expect(invocation.getDescription()).toContain("'testPattern' within"); - expect(invocation.getDescription()).toContain(path.join('src', 'app')); - }); - - it('should indicate searching across all workspace directories when no path specified', () => { - // Create a mock config with multiple directories - const multiDirConfig = { - getTargetDir: () => tempRootDir, - getWorkspaceContext: () => - createMockWorkspaceContext(tempRootDir, ['/another/dir']), - getFileExclusions: () => ({ - getGlobExcludes: () => [], - }), - } as unknown as Config; - - const multiDirGrepTool = new GrepTool(multiDirConfig); - const params: GrepToolParams = { pattern: 'testPattern' }; - const invocation = multiDirGrepTool.build(params); - expect(invocation.getDescription()).toBe( - "'testPattern' across all workspace directories", + expect(invocation.getDescription()).toContain( + "'testPattern' in path 'src", ); + expect(invocation.getDescription()).toContain("app'"); }); - it('should generate correct description with pattern, include, and path', async () => { + it('should indicate searching workspace directory when no path specified', () => { + const params: GrepToolParams = { pattern: 'testPattern' }; + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toBe("'testPattern' in path './'"); + }); + + it('should generate correct description with pattern, glob, and path', async () => { const dirPath = path.join(tempRootDir, 'src', 'app'); await fs.mkdir(dirPath, { recursive: true }); const params: GrepToolParams = { pattern: 'testPattern', - include: '*.ts', + glob: '*.ts', path: path.join('src', 'app'), }; const invocation = grepTool.build(params); expect(invocation.getDescription()).toContain( - "'testPattern' in *.ts within", + "'testPattern' in path 'src", ); - expect(invocation.getDescription()).toContain(path.join('src', 'app')); + expect(invocation.getDescription()).toContain("(filter: '*.ts')"); }); it('should use ./ for root path in description', () => { const params: GrepToolParams = { pattern: 'testPattern', path: '.' }; const invocation = grepTool.build(params); - expect(invocation.getDescription()).toBe("'testPattern' within ./"); + expect(invocation.getDescription()).toBe("'testPattern' in path '.'"); }); }); @@ -422,67 +380,50 @@ describe('GrepTool', () => { } }); - it('should limit results to default 20 matches', async () => { + it('should show all results when no limit is specified', async () => { const params: GrepToolParams = { pattern: 'testword' }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); - expect(result.llmContent).toContain('Found 20 matches'); - expect(result.llmContent).toContain( - 'showing first 20 of 30+ total matches', - ); - expect(result.llmContent).toContain('WARNING: Results truncated'); - expect(result.returnDisplay).toContain( - 'Found 20 matches (truncated from 30+)', - ); + // New implementation shows all matches when limit is not specified + expect(result.llmContent).toContain('Found 30 matches'); + expect(result.llmContent).not.toContain('truncated'); + expect(result.returnDisplay).toBe('Found 30 matches'); }); - it('should respect custom maxResults parameter', async () => { - const params: GrepToolParams = { pattern: 'testword', maxResults: 5 }; + it('should respect custom limit parameter', async () => { + const params: GrepToolParams = { pattern: 'testword', limit: 5 }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); - expect(result.llmContent).toContain('Found 5 matches'); - expect(result.llmContent).toContain( - 'showing first 5 of 30+ total matches', - ); - expect(result.llmContent).toContain('current: 5'); - expect(result.returnDisplay).toContain( - 'Found 5 matches (truncated from 30+)', - ); + // Should find 30 total but limit to 5 + expect(result.llmContent).toContain('Found 30 matches'); + expect(result.llmContent).toContain('25 lines truncated'); + expect(result.returnDisplay).toContain('Found 30 matches (truncated)'); }); it('should not show truncation warning when all results fit', async () => { - const params: GrepToolParams = { pattern: 'testword', maxResults: 50 }; + const params: GrepToolParams = { pattern: 'testword', limit: 50 }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 30 matches'); - expect(result.llmContent).not.toContain('WARNING: Results truncated'); - expect(result.llmContent).not.toContain('showing first'); + expect(result.llmContent).not.toContain('truncated'); expect(result.returnDisplay).toBe('Found 30 matches'); }); - it('should validate maxResults parameter', () => { - const invalidParams = [ - { pattern: 'test', maxResults: 0 }, - { pattern: 'test', maxResults: 101 }, - { pattern: 'test', maxResults: -1 }, - { pattern: 'test', maxResults: 1.5 }, - ]; - - invalidParams.forEach((params) => { - const error = grepTool.validateToolParams(params as GrepToolParams); - expect(error).toBeTruthy(); // Just check that validation fails - expect(error).toMatch(/maxResults|must be/); // Check it's about maxResults validation - }); + it('should not validate limit parameter', () => { + // limit parameter has no validation constraints in the new implementation + const params = { pattern: 'test', limit: 5 }; + const error = grepTool.validateToolParams(params as GrepToolParams); + expect(error).toBeNull(); }); - it('should accept valid maxResults parameter', () => { + it('should accept valid limit parameter', () => { const validParams = [ - { pattern: 'test', maxResults: 1 }, - { pattern: 'test', maxResults: 50 }, - { pattern: 'test', maxResults: 100 }, + { pattern: 'test', limit: 1 }, + { pattern: 'test', limit: 50 }, + { pattern: 'test', limit: 100 }, ]; validParams.forEach((params) => { diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 08f651ac..df410f0c 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { EOL } from 'node:os'; @@ -12,8 +11,8 @@ import { spawn } from 'node:child_process'; import { globStream } from 'glob'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import { ToolNames } from './tool-names.js'; -import { makeRelative, shortenPath } from '../utils/paths.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import { resolveAndValidatePath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { isGitRepository } from '../utils/gitUtils.js'; import type { Config } from '../config/config.js'; @@ -37,14 +36,14 @@ export interface GrepToolParams { path?: string; /** - * File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}") + * Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") */ - include?: string; + glob?: string; /** - * Maximum number of matches to return (optional, defaults to 20) + * Maximum number of matching lines to return (optional, shows all if not specified) */ - maxResults?: number; + limit?: number; } /** @@ -70,121 +69,60 @@ class GrepToolInvocation extends BaseToolInvocation< this.fileExclusions = config.getFileExclusions(); } - /** - * Checks if a path is within the root directory and resolves it. - * @param relativePath Path relative to the root directory (or undefined for root). - * @returns The absolute path if valid and exists, or null if no path specified (to search all directories). - * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. - */ - private resolveAndValidatePath(relativePath?: string): string | null { - // If no path specified, return null to indicate searching all workspace directories - if (!relativePath) { - return null; - } - - const targetPath = path.resolve(this.config.getTargetDir(), relativePath); - - // Security Check: Ensure the resolved path is within workspace boundaries - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(targetPath)) { - const directories = workspaceContext.getDirectories(); - throw new Error( - `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, - ); - } - - // Check existence and type after resolving - try { - const stats = fs.statSync(targetPath); - if (!stats.isDirectory()) { - throw new Error(`Path is not a directory: ${targetPath}`); - } - } catch (error: unknown) { - if (isNodeError(error) && error.code !== 'ENOENT') { - throw new Error(`Path does not exist: ${targetPath}`); - } - throw new Error( - `Failed to access path stats for ${targetPath}: ${error}`, - ); - } - - return targetPath; - } - async execute(signal: AbortSignal): Promise { try { - const workspaceContext = this.config.getWorkspaceContext(); - const searchDirAbs = this.resolveAndValidatePath(this.params.path); + // Default to target directory if no path is provided + const searchDirAbs = resolveAndValidatePath( + this.config, + this.params.path, + ); const searchDirDisplay = this.params.path || '.'; - // Determine which directories to search - let searchDirectories: readonly string[]; - if (searchDirAbs === null) { - // No path specified - search all workspace directories - searchDirectories = workspaceContext.getDirectories(); - } else { - // Specific path provided - search only that directory - searchDirectories = [searchDirAbs]; - } + // Perform grep search + const rawMatches = await this.performGrepSearch({ + pattern: this.params.pattern, + path: searchDirAbs, + glob: this.params.glob, + signal, + }); - // Collect matches from all search directories - let allMatches: GrepMatch[] = []; - const maxResults = this.params.maxResults ?? 20; // Default to 20 results - let totalMatchesFound = 0; - let searchTruncated = false; + // Build search description + const searchLocationDescription = this.params.path + ? `in path "${searchDirDisplay}"` + : `in the workspace directory`; - for (const searchDir of searchDirectories) { - const matches = await this.performGrepSearch({ - pattern: this.params.pattern, - path: searchDir, - include: this.params.include, - signal, - }); + const filterDescription = this.params.glob + ? ` (filter: "${this.params.glob}")` + : ''; - totalMatchesFound += matches.length; - - // Add directory prefix if searching multiple directories - if (searchDirectories.length > 1) { - const dirName = path.basename(searchDir); - matches.forEach((match) => { - match.filePath = path.join(dirName, match.filePath); - }); - } - - // Apply result limiting - const remainingSlots = maxResults - allMatches.length; - if (remainingSlots <= 0) { - searchTruncated = true; - break; - } - - if (matches.length > remainingSlots) { - allMatches = allMatches.concat(matches.slice(0, remainingSlots)); - searchTruncated = true; - break; - } else { - allMatches = allMatches.concat(matches); - } - } - - let searchLocationDescription: string; - if (searchDirAbs === null) { - const numDirs = workspaceContext.getDirectories().length; - searchLocationDescription = - numDirs > 1 - ? `across ${numDirs} workspace directories` - : `in the workspace directory`; - } else { - searchLocationDescription = `in path "${searchDirDisplay}"`; - } - - if (allMatches.length === 0) { - const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; + // Check if we have any matches + if (rawMatches.length === 0) { + const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}.`; return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; } + const charLimit = this.config.getTruncateToolOutputThreshold(); + const lineLimit = Math.min( + this.config.getTruncateToolOutputLines(), + this.params.limit ?? Number.POSITIVE_INFINITY, + ); + + // Apply line limit if specified + let truncatedByLineLimit = false; + let matchesToInclude = rawMatches; + if (rawMatches.length > lineLimit) { + matchesToInclude = rawMatches.slice(0, lineLimit); + truncatedByLineLimit = true; + } + + const totalMatches = rawMatches.length; + const matchTerm = totalMatches === 1 ? 'match' : 'matches'; + + // Build header + const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`; + // Group matches by file - const matchesByFile = allMatches.reduce( + const matchesByFile = matchesToInclude.reduce( (acc, match) => { const fileKey = match.filePath; if (!acc[fileKey]) { @@ -197,46 +135,51 @@ class GrepToolInvocation extends BaseToolInvocation< {} as Record, ); - const matchCount = allMatches.length; - const matchTerm = matchCount === 1 ? 'match' : 'matches'; - - // Build the header with truncation info if needed - let headerText = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`; - - if (searchTruncated) { - headerText += ` (showing first ${matchCount} of ${totalMatchesFound}+ total matches)`; - } - - let llmContent = `${headerText}: ---- -`; - + // Build grep output + let grepOutput = ''; for (const filePath in matchesByFile) { - llmContent += `File: ${filePath}\n`; + grepOutput += `File: ${filePath}\n`; matchesByFile[filePath].forEach((match) => { const trimmedLine = match.line.trim(); - llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; + grepOutput += `L${match.lineNumber}: ${trimmedLine}\n`; }); - llmContent += '---\n'; + grepOutput += '---\n'; } - // Add truncation guidance if results were limited - if (searchTruncated) { - llmContent += `\nWARNING: Results truncated to prevent context overflow. To see more results: -- Use a more specific pattern to reduce matches -- Add file filters with the 'include' parameter (e.g., "*.js", "src/**") -- Specify a narrower 'path' to search in a subdirectory -- Increase 'maxResults' parameter if you need more matches (current: ${maxResults})`; + // Apply character limit as safety net + let truncatedByCharLimit = false; + if (Number.isFinite(charLimit) && grepOutput.length > charLimit) { + grepOutput = grepOutput.slice(0, charLimit) + '...'; + truncatedByCharLimit = true; } - let displayText = `Found ${matchCount} ${matchTerm}`; - if (searchTruncated) { - displayText += ` (truncated from ${totalMatchesFound}+)`; + // Count how many lines we actually included after character truncation + const finalLines = grepOutput + .split('\n') + .filter( + (line) => + line.trim() && !line.startsWith('File:') && !line.startsWith('---'), + ); + const includedLines = finalLines.length; + + // Build result + let llmContent = header + grepOutput; + + // Add truncation notice if needed + if (truncatedByLineLimit || truncatedByCharLimit) { + const omittedMatches = totalMatches - includedLines; + llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`; + } + + // Build display message + let displayMessage = `Found ${totalMatches} ${matchTerm}`; + if (truncatedByLineLimit || truncatedByCharLimit) { + displayMessage += ` (truncated)`; } return { llmContent: llmContent.trim(), - returnDisplay: displayText, + returnDisplay: displayMessage, }; } catch (error) { console.error(`Error during GrepLogic execution: ${error}`); @@ -329,50 +272,26 @@ class GrepToolInvocation extends BaseToolInvocation< * @returns A string describing the grep */ getDescription(): string { - let description = `'${this.params.pattern}'`; - if (this.params.include) { - description += ` in ${this.params.include}`; - } - if (this.params.path) { - const resolvedPath = path.resolve( - this.config.getTargetDir(), - this.params.path, - ); - if ( - resolvedPath === this.config.getTargetDir() || - this.params.path === '.' - ) { - description += ` within ./`; - } else { - const relativePath = makeRelative( - resolvedPath, - this.config.getTargetDir(), - ); - description += ` within ${shortenPath(relativePath)}`; - } - } else { - // When no path is specified, indicate searching all workspace directories - const workspaceContext = this.config.getWorkspaceContext(); - const directories = workspaceContext.getDirectories(); - if (directories.length > 1) { - description += ` across all workspace directories`; - } + let description = `'${this.params.pattern}' in path '${this.params.path || './'}'`; + if (this.params.glob) { + description += ` (filter: '${this.params.glob}')`; } + return description; } /** * Performs the actual search using the prioritized strategies. - * @param options Search options including pattern, absolute path, and include glob. + * @param options Search options including pattern, absolute path, and glob filter. * @returns A promise resolving to an array of match objects. */ private async performGrepSearch(options: { pattern: string; path: string; // Expects absolute path - include?: string; + glob?: string; signal: AbortSignal; }): Promise { - const { pattern, path: absolutePath, include } = options; + const { pattern, path: absolutePath, glob } = options; let strategyUsed = 'none'; try { @@ -390,8 +309,8 @@ class GrepToolInvocation extends BaseToolInvocation< '--ignore-case', pattern, ]; - if (include) { - gitArgs.push('--', include); + if (glob) { + gitArgs.push('--', glob); } try { @@ -457,8 +376,8 @@ class GrepToolInvocation extends BaseToolInvocation< }) .filter((dir): dir is string => !!dir); commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`)); - if (include) { - grepArgs.push(`--include=${include}`); + if (glob) { + grepArgs.push(`--include=${glob}`); } grepArgs.push(pattern); grepArgs.push('.'); @@ -537,7 +456,7 @@ class GrepToolInvocation extends BaseToolInvocation< 'GrepLogic: Falling back to JavaScript grep implementation.', ); strategyUsed = 'javascript fallback'; - const globPattern = include ? include : '**/*'; + const globPattern = glob ? glob : '**/*'; const ignorePatterns = this.fileExclusions.getGlobExcludes(); const filesIterator = globStream(globPattern, { @@ -603,32 +522,30 @@ export class GrepTool extends BaseDeclarativeTool { constructor(private readonly config: Config) { super( GrepTool.Name, - 'SearchText', - 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.', + ToolDisplayNames.GREP, + 'A powerful search tool for finding patterns in files\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx")\n - Case-insensitive by default\n - Use Task tool for open-ended searches requiring multiple rounds\n', Kind.Search, { properties: { pattern: { - description: - "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", type: 'string', + description: + 'The regular expression pattern to search for in file contents', + }, + glob: { + type: 'string', + description: + 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")', }, path: { - description: - 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', type: 'string', - }, - include: { description: - "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", - type: 'string', + 'File or directory to search in. Defaults to current working directory.', }, - maxResults: { - description: - 'Optional: Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.', + limit: { type: 'number', - minimum: 1, - maximum: 100, + description: + 'Limit output to first N matching lines. Optional - shows all matches if not specified.', }, }, required: ['pattern'], @@ -637,47 +554,6 @@ export class GrepTool extends BaseDeclarativeTool { ); } - /** - * Checks if a path is within the root directory and resolves it. - * @param relativePath Path relative to the root directory (or undefined for root). - * @returns The absolute path if valid and exists, or null if no path specified (to search all directories). - * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. - */ - private resolveAndValidatePath(relativePath?: string): string | null { - // If no path specified, return null to indicate searching all workspace directories - if (!relativePath) { - return null; - } - - const targetPath = path.resolve(this.config.getTargetDir(), relativePath); - - // Security Check: Ensure the resolved path is within workspace boundaries - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(targetPath)) { - const directories = workspaceContext.getDirectories(); - throw new Error( - `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, - ); - } - - // Check existence and type after resolving - try { - const stats = fs.statSync(targetPath); - if (!stats.isDirectory()) { - throw new Error(`Path is not a directory: ${targetPath}`); - } - } catch (error: unknown) { - if (isNodeError(error) && error.code !== 'ENOENT') { - throw new Error(`Path does not exist: ${targetPath}`); - } - throw new Error( - `Failed to access path stats for ${targetPath}: ${error}`, - ); - } - - return targetPath; - } - /** * Validates the parameters for the tool * @param params Parameters to validate @@ -686,27 +562,17 @@ export class GrepTool extends BaseDeclarativeTool { protected override validateToolParamValues( params: GrepToolParams, ): string | null { + // Validate pattern is a valid regex try { new RegExp(params.pattern); } catch (error) { - return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`; - } - - // Validate maxResults if provided - if (params.maxResults !== undefined) { - if ( - !Number.isInteger(params.maxResults) || - params.maxResults < 1 || - params.maxResults > 100 - ) { - return `maxResults must be an integer between 1 and 100, got: ${params.maxResults}`; - } + return `Invalid regular expression pattern: ${params.pattern}. Error: ${getErrorMessage(error)}`; } // Only validate path if one is provided if (params.path) { try { - this.resolveAndValidatePath(params.path); + resolveAndValidatePath(this.config, params.path); } catch (error) { return getErrorMessage(error); } diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 16ee1ec7..2aefe443 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -12,6 +12,7 @@ import { makeRelative, shortenPath } from '../utils/paths.js'; import type { Config } from '../config/config.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { ToolErrorType } from './tool-error.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; /** * Parameters for the LS tool @@ -252,12 +253,12 @@ class LSToolInvocation extends BaseToolInvocation { * Implementation of the LS tool logic */ export class LSTool extends BaseDeclarativeTool { - static readonly Name = 'list_directory'; + static readonly Name = ToolNames.LS; constructor(private config: Config) { super( LSTool.Name, - 'ReadFolder', + ToolDisplayNames.LS, 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.', Kind.Search, { diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 42c4a661..dc6bebef 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -18,6 +18,7 @@ import { Storage } from '../config/storage.js'; import * as Diff from 'diff'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { tildeifyPath } from '../utils/paths.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; import type { ModifiableDeclarativeTool, ModifyContext, @@ -380,11 +381,11 @@ export class MemoryTool extends BaseDeclarativeTool implements ModifiableDeclarativeTool { - static readonly Name: string = memoryToolSchemaData.name!; + static readonly Name: string = ToolNames.MEMORY; constructor() { super( MemoryTool.Name, - 'SaveMemory', + ToolDisplayNames.MEMORY, memoryToolDescription, Kind.Think, memoryToolSchemaData.parametersJsonSchema as Record, diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index dfb12c94..a7aa6648 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -41,6 +41,8 @@ describe('ReadFileTool', () => { storage: { getProjectTempDir: () => path.join(tempRootDir, '.temp'), }, + getTruncateToolOutputThreshold: () => 2500, + getTruncateToolOutputLines: () => 500, } as unknown as Config; tool = new ReadFileTool(mockConfigInstance); }); @@ -281,11 +283,9 @@ describe('ReadFileTool', () => { >; const result = await invocation.execute(abortSignal); - expect(result.llmContent).toContain( - 'IMPORTANT: The file content has been truncated', + expect(result.returnDisplay).toContain( + 'Read lines 1-2 of 3 from longlines.txt (truncated)', ); - expect(result.llmContent).toContain('--- FILE CONTENT (truncated) ---'); - expect(result.returnDisplay).toContain('some lines were shortened'); }); it('should handle image file and return appropriate content', async () => { @@ -417,10 +417,7 @@ describe('ReadFileTool', () => { const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( - 'IMPORTANT: The file content has been truncated', - ); - expect(result.llmContent).toContain( - 'Status: Showing lines 6-8 of 20 total lines', + 'Showing lines 6-8 of 20 total lines', ); expect(result.llmContent).toContain('Line 6'); expect(result.llmContent).toContain('Line 7'); diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index fa26b3c6..a9e47ccf 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { makeRelative, shortenPath } from '../utils/paths.js'; import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import { ToolNames } from './tool-names.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; import type { PartUnion } from '@google/genai'; import { @@ -67,8 +67,7 @@ class ReadFileToolInvocation extends BaseToolInvocation< async execute(): Promise { const result = await processSingleFileContent( this.params.absolute_path, - this.config.getTargetDir(), - this.config.getFileSystemService(), + this.config, this.params.offset, this.params.limit, ); @@ -88,16 +87,7 @@ class ReadFileToolInvocation extends BaseToolInvocation< if (result.isTruncated) { const [start, end] = result.linesShown!; const total = result.originalLineCount!; - const nextOffset = this.params.offset - ? this.params.offset + end - start + 1 - : end; - llmContent = ` -IMPORTANT: The file content has been truncated. -Status: Showing lines ${start}-${end} of ${total} total lines. -Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}. - ---- FILE CONTENT (truncated) --- -${result.llmContent}`; + llmContent = `Showing lines ${start}-${end} of ${total} total lines.\n\n---\n\n${result.llmContent}`; } else { llmContent = result.llmContent || ''; } @@ -141,7 +131,7 @@ export class ReadFileTool extends BaseDeclarativeTool< constructor(private config: Config) { super( ReadFileTool.Name, - 'ReadFile', + ToolDisplayNames.READ_FILE, `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges.`, Kind.Read, { diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 0b4fefb5..758fb5d6 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -88,6 +88,8 @@ describe('ReadManyFilesTool', () => { buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES, getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES, }), + getTruncateToolOutputThreshold: () => 2500, + getTruncateToolOutputLines: () => 500, } as Partial as Config; tool = new ReadManyFilesTool(mockConfig); @@ -500,6 +502,8 @@ describe('ReadManyFilesTool', () => { buildExcludePatterns: () => [], getReadManyFilesExcludes: () => [], }), + getTruncateToolOutputThreshold: () => 2500, + getTruncateToolOutputLines: () => 500, } as Partial as Config; tool = new ReadManyFilesTool(mockConfig); @@ -552,15 +556,10 @@ describe('ReadManyFilesTool', () => { c.includes('large-file.txt'), ); - expect(normalFileContent).not.toContain( - '[WARNING: This file was truncated.', - ); + expect(normalFileContent).not.toContain('Showing lines'); expect(truncatedFileContent).toContain( - "[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]", + 'Showing lines 1-250 of 2500 total lines.', ); - // Check that the actual content is still there but truncated - expect(truncatedFileContent).toContain('L200'); - expect(truncatedFileContent).not.toContain('L2400'); }); it('should read files with special characters like [] and () in the path', async () => { diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 63fcf78a..33ea3339 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -6,7 +6,7 @@ import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import { ToolNames } from './tool-names.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; import { getErrorMessage } from '../utils/errors.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -17,7 +17,6 @@ import { processSingleFileContent, DEFAULT_ENCODING, getSpecificMimeType, - DEFAULT_MAX_LINES_TEXT_FILE, } from '../utils/fileUtils.js'; import type { PartListUnion } from '@google/genai'; import { @@ -278,8 +277,10 @@ ${finalExclusionPatternsForDescription } const sortedFiles = Array.from(filesToConsider).sort(); - const file_line_limit = - DEFAULT_MAX_LINES_TEXT_FILE / Math.max(1, sortedFiles.length); + const truncateToolOutputLines = this.config.getTruncateToolOutputLines(); + const file_line_limit = Number.isFinite(truncateToolOutputLines) + ? Math.floor(truncateToolOutputLines / Math.max(1, sortedFiles.length)) + : undefined; const fileProcessingPromises = sortedFiles.map( async (filePath): Promise => { @@ -316,8 +317,7 @@ ${finalExclusionPatternsForDescription // Use processSingleFileContent for all file types now const fileReadResult = await processSingleFileContent( filePath, - this.config.getTargetDir(), - this.config.getFileSystemService(), + this.config, 0, file_line_limit, ); @@ -376,9 +376,12 @@ ${finalExclusionPatternsForDescription ); let fileContentForLlm = ''; if (fileReadResult.isTruncated) { - fileContentForLlm += `[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]\n\n`; + const [start, end] = fileReadResult.linesShown!; + const total = fileReadResult.originalLineCount!; + fileContentForLlm = `Showing lines ${start}-${end} of ${total} total lines.\n---\n${fileReadResult.llmContent}`; + } else { + fileContentForLlm = fileReadResult.llmContent; } - fileContentForLlm += fileReadResult.llmContent; contentParts.push(`${separator}\n\n${fileContentForLlm}\n\n`); } else { // This is a Part for image/pdf, which we don't add the separator to. @@ -551,7 +554,7 @@ export class ReadManyFilesTool extends BaseDeclarativeTool< super( ReadManyFilesTool.Name, - 'ReadManyFiles', + ToolDisplayNames.READ_MANY_FILES, `Reads content from multiple files specified by paths or glob patterns within a configured target directory. For text files, it concatenates their content into a single string. It is primarily designed for text-based files. However, it can also process image (e.g., .png, .jpg) and PDF (.pdf) files if their file names or extensions are explicitly included in the 'paths' argument. For these explicitly requested non-text files, their data is read and included in a format suitable for model consumption (e.g., base64 encoded). This tool is useful when you need to understand or analyze a collection of files, such as: diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 19ac5ce3..a2f813f4 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -103,6 +103,8 @@ describe('RipGrepTool', () => { getWorkingDir: () => tempRootDir, getDebugMode: () => false, getUseBuiltinRipgrep: () => true, + getTruncateToolOutputThreshold: () => 25000, + getTruncateToolOutputLines: () => 1000, } as unknown as Config; beforeEach(async () => { @@ -184,17 +186,15 @@ describe('RipGrepTool', () => { }; // Check for the core error message, as the full path might vary expect(grepTool.validateToolParams(params)).toContain( - 'Failed to access path stats for', + 'Path does not exist:', ); expect(grepTool.validateToolParams(params)).toContain('nonexistent'); }); - it('should return error if path is a file, not a directory', async () => { + it('should allow path to be a file', () => { const filePath = path.join(tempRootDir, 'fileA.txt'); const params: RipGrepToolParams = { pattern: 'hello', path: filePath }; - expect(grepTool.validateToolParams(params)).toContain( - `Path is not a directory: ${filePath}`, - ); + expect(grepTool.validateToolParams(params)).toBeNull(); }); }); @@ -419,7 +419,7 @@ describe('RipGrepTool', () => { }); it('should truncate llm content when exceeding maximum length', async () => { - const longMatch = 'fileA.txt:1:' + 'a'.repeat(25_000); + const longMatch = 'fileA.txt:1:' + 'a'.repeat(30_000); mockSpawn.mockImplementationOnce( createMockSpawn({ @@ -432,7 +432,7 @@ describe('RipGrepTool', () => { const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); - expect(String(result.llmContent).length).toBeLessThanOrEqual(20_000); + expect(String(result.llmContent).length).toBeLessThanOrEqual(26_000); expect(result.llmContent).toMatch(/\[\d+ lines? truncated\] \.\.\./); expect(result.returnDisplay).toContain('truncated'); }); @@ -567,6 +567,26 @@ describe('RipGrepTool', () => { ); }); + it('should search within a single file when path is a file', async () => { + mockSpawn.mockImplementationOnce( + createMockSpawn({ + outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`, + exitCode: 0, + }), + ); + + const params: RipGrepToolParams = { + pattern: 'world', + path: path.join(tempRootDir, 'fileA.txt'), + }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain('Found 2 matches'); + expect(result.llmContent).toContain('fileA.txt:1:hello world'); + expect(result.llmContent).toContain('fileA.txt:2:second line with world'); + expect(result.returnDisplay).toBe('Found 2 matches'); + }); + it('should throw an error if ripgrep is not available', async () => { // Make ensureRipgrepBinary throw (ensureRipgrepPath as Mock).mockRejectedValue( @@ -648,7 +668,9 @@ describe('RipGrepTool', () => { describe('error handling and edge cases', () => { it('should handle workspace boundary violations', () => { const params: RipGrepToolParams = { pattern: 'test', path: '../outside' }; - expect(() => grepTool.build(params)).toThrow(/Path validation failed/); + expect(() => grepTool.build(params)).toThrow( + /Path is not within workspace/, + ); }); it('should handle empty directories gracefully', async () => { @@ -1132,7 +1154,9 @@ describe('RipGrepTool', () => { glob: '*.ts', }; const invocation = grepTool.build(params); - expect(invocation.getDescription()).toBe("'testPattern' in *.ts"); + expect(invocation.getDescription()).toBe( + "'testPattern' (filter: '*.ts')", + ); }); it('should generate correct description with pattern and path', async () => { @@ -1143,9 +1167,10 @@ describe('RipGrepTool', () => { path: path.join('src', 'app'), }; const invocation = grepTool.build(params); - // The path will be relative to the tempRootDir, so we check for containment. - expect(invocation.getDescription()).toContain("'testPattern' within"); - expect(invocation.getDescription()).toContain(path.join('src', 'app')); + expect(invocation.getDescription()).toContain( + "'testPattern' in path 'src", + ); + expect(invocation.getDescription()).toContain("app'"); }); it('should generate correct description with default search path', () => { @@ -1164,15 +1189,15 @@ describe('RipGrepTool', () => { }; const invocation = grepTool.build(params); expect(invocation.getDescription()).toContain( - "'testPattern' in *.ts within", + "'testPattern' in path 'src", ); - expect(invocation.getDescription()).toContain(path.join('src', 'app')); + expect(invocation.getDescription()).toContain("(filter: '*.ts')"); }); - it('should use ./ for root path in description', () => { + it('should use path when specified in description', () => { const params: RipGrepToolParams = { pattern: 'testPattern', path: '.' }; const invocation = grepTool.build(params); - expect(invocation.getDescription()).toBe("'testPattern' within ./"); + expect(invocation.getDescription()).toBe("'testPattern' in path '.'"); }); }); }); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 073db8e9..80273f31 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -11,16 +11,14 @@ import { spawn } from 'node:child_process'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolNames } from './tool-names.js'; -import { makeRelative, shortenPath } from '../utils/paths.js'; -import { getErrorMessage, isNodeError } from '../utils/errors.js'; +import { resolveAndValidatePath } from '../utils/paths.js'; +import { getErrorMessage } from '../utils/errors.js'; import type { Config } from '../config/config.js'; import { ensureRipgrepPath } from '../utils/ripgrepUtils.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import type { FileFilteringOptions } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; -const MAX_LLM_CONTENT_LENGTH = 20_000; - /** * Parameters for the GrepTool (Simplified) */ @@ -57,50 +55,13 @@ class GrepToolInvocation extends BaseToolInvocation< super(params); } - /** - * Checks if a path is within the root directory and resolves it. - * @param relativePath Path relative to the root directory (or undefined for root). - * @returns The absolute path to search within. - * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. - */ - private resolveAndValidatePath(relativePath?: string): string { - const targetDir = this.config.getTargetDir(); - const targetPath = relativePath - ? path.resolve(targetDir, relativePath) - : targetDir; - - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(targetPath)) { - const directories = workspaceContext.getDirectories(); - throw new Error( - `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, - ); - } - - return this.ensureDirectory(targetPath); - } - - private ensureDirectory(targetPath: string): string { - try { - const stats = fs.statSync(targetPath); - if (!stats.isDirectory()) { - throw new Error(`Path is not a directory: ${targetPath}`); - } - } catch (error: unknown) { - if (isNodeError(error) && error.code !== 'ENOENT') { - throw new Error(`Path does not exist: ${targetPath}`); - } - throw new Error( - `Failed to access path stats for ${targetPath}: ${error}`, - ); - } - - return targetPath; - } - async execute(signal: AbortSignal): Promise { try { - const searchDirAbs = this.resolveAndValidatePath(this.params.path); + const searchDirAbs = resolveAndValidatePath( + this.config, + this.params.path, + { allowFiles: true }, + ); const searchDirDisplay = this.params.path || '.'; // Get raw ripgrep output @@ -133,34 +94,50 @@ class GrepToolInvocation extends BaseToolInvocation< // Build header early to calculate available space const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`; - const maxTruncationNoticeLength = 100; // "[... N more matches truncated]" - const maxGrepOutputLength = - MAX_LLM_CONTENT_LENGTH - header.length - maxTruncationNoticeLength; + + const charLimit = this.config.getTruncateToolOutputThreshold(); + const lineLimit = Math.min( + this.config.getTruncateToolOutputLines(), + this.params.limit ?? Number.POSITIVE_INFINITY, + ); // Apply line limit first (if specified) let truncatedByLineLimit = false; let linesToInclude = allLines; - if ( - this.params.limit !== undefined && - allLines.length > this.params.limit - ) { - linesToInclude = allLines.slice(0, this.params.limit); + if (allLines.length > lineLimit) { + linesToInclude = allLines.slice(0, lineLimit); truncatedByLineLimit = true; } - // Join lines back into grep output - let grepOutput = linesToInclude.join(EOL); - - // Apply character limit as safety net + // Build output and track how many lines we include, respecting character limit + let grepOutput = ''; let truncatedByCharLimit = false; - if (grepOutput.length > maxGrepOutputLength) { - grepOutput = grepOutput.slice(0, maxGrepOutputLength) + '...'; - truncatedByCharLimit = true; - } + let includedLines = 0; + if (Number.isFinite(charLimit)) { + const parts: string[] = []; + let currentLength = 0; - // Count how many lines we actually included after character truncation - const finalLines = grepOutput.split(EOL).filter((line) => line.trim()); - const includedLines = finalLines.length; + for (const line of linesToInclude) { + const sep = includedLines > 0 ? 1 : 0; + includedLines++; + + const projectedLength = currentLength + line.length + sep; + if (projectedLength <= charLimit) { + parts.push(line); + currentLength = projectedLength; + } else { + const remaining = Math.max(charLimit - currentLength - sep, 10); + parts.push(line.slice(0, remaining) + '...'); + truncatedByCharLimit = true; + break; + } + } + + grepOutput = parts.join('\n'); + } else { + grepOutput = linesToInclude.join('\n'); + includedLines = linesToInclude.length; + } // Build result let llmContent = header + grepOutput; @@ -168,7 +145,7 @@ class GrepToolInvocation extends BaseToolInvocation< // Add truncation notice if needed if (truncatedByLineLimit || truncatedByCharLimit) { const omittedMatches = totalMatches - includedLines; - llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`; + llmContent += `\n---\n[${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`; } // Build display message (show real count, not truncated) @@ -193,7 +170,7 @@ class GrepToolInvocation extends BaseToolInvocation< private async performRipgrepSearch(options: { pattern: string; - path: string; + path: string; // Can be a file or directory glob?: string; signal: AbortSignal; }): Promise { @@ -302,34 +279,13 @@ class GrepToolInvocation extends BaseToolInvocation< */ getDescription(): string { let description = `'${this.params.pattern}'`; - if (this.params.glob) { - description += ` in ${this.params.glob}`; - } if (this.params.path) { - const resolvedPath = path.resolve( - this.config.getTargetDir(), - this.params.path, - ); - if ( - resolvedPath === this.config.getTargetDir() || - this.params.path === '.' - ) { - description += ` within ./`; - } else { - const relativePath = makeRelative( - resolvedPath, - this.config.getTargetDir(), - ); - description += ` within ${shortenPath(relativePath)}`; - } - } else { - // When no path is specified, indicate searching all workspace directories - const workspaceContext = this.config.getWorkspaceContext(); - const directories = workspaceContext.getDirectories(); - if (directories.length > 1) { - description += ` across all workspace directories`; - } + description += ` in path '${this.params.path}'`; } + if (this.params.glob) { + description += ` (filter: '${this.params.glob}')`; + } + return description; } } @@ -378,47 +334,6 @@ export class RipGrepTool extends BaseDeclarativeTool< ); } - /** - * Checks if a path is within the root directory and resolves it. - * @param relativePath Path relative to the root directory (or undefined for root). - * @returns The absolute path to search within. - * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. - */ - private resolveAndValidatePath(relativePath?: string): string { - // If no path specified, search within the workspace root directory - if (!relativePath) { - return this.config.getTargetDir(); - } - - const targetPath = path.resolve(this.config.getTargetDir(), relativePath); - - // Security Check: Ensure the resolved path is within workspace boundaries - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(targetPath)) { - const directories = workspaceContext.getDirectories(); - throw new Error( - `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, - ); - } - - // Check existence and type after resolving - try { - const stats = fs.statSync(targetPath); - if (!stats.isDirectory()) { - throw new Error(`Path is not a directory: ${targetPath}`); - } - } catch (error: unknown) { - if (isNodeError(error) && error.code !== 'ENOENT') { - throw new Error(`Path does not exist: ${targetPath}`); - } - throw new Error( - `Failed to access path stats for ${targetPath}: ${error}`, - ); - } - - return targetPath; - } - /** * Validates the parameters for the tool * @param params Parameters to validate @@ -445,7 +360,7 @@ export class RipGrepTool extends BaseDeclarativeTool< // Only validate path if one is provided if (params.path) { try { - this.resolveAndValidatePath(params.path); + resolveAndValidatePath(this.config, params.path, { allowFiles: true }); } catch (error) { return getErrorMessage(error); } diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index b5f48672..17e40dbe 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import os, { EOL } from 'node:os'; import crypto from 'node:crypto'; import type { Config } from '../config/config.js'; -import { ToolNames } from './tool-names.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; import { ToolErrorType } from './tool-error.js'; import type { ToolInvocation, @@ -429,7 +429,7 @@ export class ShellTool extends BaseDeclarativeTool< constructor(private readonly config: Config) { super( ShellTool.Name, - 'Shell', + ToolDisplayNames.SHELL, getShellToolDescription(), Kind.Execute, { diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index 25340084..67f03f5f 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -5,7 +5,7 @@ */ import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; -import { ToolNames } from './tool-names.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; import type { ToolResult, ToolResultDisplay, @@ -77,7 +77,7 @@ export class TaskTool extends BaseDeclarativeTool { super( TaskTool.Name, - 'Task', + ToolDisplayNames.TASK, 'Delegate tasks to specialized subagents. Loading available subagents...', // Initial description Kind.Other, initialSchema, diff --git a/packages/core/src/tools/todoWrite.ts b/packages/core/src/tools/todoWrite.ts index ffd0bcbb..23deb260 100644 --- a/packages/core/src/tools/todoWrite.ts +++ b/packages/core/src/tools/todoWrite.ts @@ -14,6 +14,7 @@ import * as process from 'process'; import { QWEN_DIR } from '../utils/paths.js'; import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; export interface TodoItem { id: string; @@ -422,12 +423,12 @@ export class TodoWriteTool extends BaseDeclarativeTool< TodoWriteParams, ToolResult > { - static readonly Name: string = todoWriteToolSchemaData.name!; + static readonly Name: string = ToolNames.TODO_WRITE; constructor(private readonly config: Config) { super( TodoWriteTool.Name, - 'TodoWrite', + ToolDisplayNames.TODO_WRITE, todoWriteToolDescription, Kind.Think, todoWriteToolSchemaData.parametersJsonSchema as Record, diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 09109205..22be9c1b 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -21,4 +21,45 @@ export const ToolNames = { MEMORY: 'save_memory', TASK: 'task', EXIT_PLAN_MODE: 'exit_plan_mode', + WEB_FETCH: 'web_fetch', + WEB_SEARCH: 'web_search', + LS: 'list_directory', +} as const; + +/** + * Tool display name constants to avoid circular dependencies. + * These constants are used across multiple files and should be kept in sync + * with the actual tool display names. + */ +export const ToolDisplayNames = { + EDIT: 'Edit', + WRITE_FILE: 'WriteFile', + READ_FILE: 'ReadFile', + READ_MANY_FILES: 'ReadManyFiles', + GREP: 'Grep', + GLOB: 'Glob', + SHELL: 'Shell', + TODO_WRITE: 'TodoWrite', + MEMORY: 'SaveMemory', + TASK: 'Task', + EXIT_PLAN_MODE: 'ExitPlanMode', + WEB_FETCH: 'WebFetch', + WEB_SEARCH: 'WebSearch', + LS: 'ListFiles', +} as const; + +// Migration from old tool names to new tool names +// These legacy tool names were used in earlier versions and need to be supported +// for backward compatibility with existing user configurations +export const ToolNamesMigration = { + search_file_content: ToolNames.GREP, // Legacy name from grep tool + replace: ToolNames.EDIT, // Legacy name from edit tool +} as const; + +// Migration from old tool display names to new tool display names +// These legacy display names were used before the tool naming standardization +export const ToolDisplayNamesMigration = { + SearchFiles: ToolDisplayNames.GREP, // Old display name for Grep + FindFiles: ToolDisplayNames.GLOB, // Old display name for Glob + ReadFolder: ToolDisplayNames.LS, // Old display name for ListFiles } as const; diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 0d253d00..7797659e 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -23,6 +23,7 @@ import { ToolConfirmationOutcome, } from './tools.js'; import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; const URL_FETCH_TIMEOUT_MS = 10000; const MAX_CONTENT_LENGTH = 100000; @@ -190,12 +191,12 @@ export class WebFetchTool extends BaseDeclarativeTool< WebFetchToolParams, ToolResult > { - static readonly Name: string = 'web_fetch'; + static readonly Name: string = ToolNames.WEB_FETCH; constructor(private readonly config: Config) { super( WebFetchTool.Name, - 'WebFetch', + ToolDisplayNames.WEB_FETCH, 'Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large\n - Supports both public and private/localhost URLs using direct fetch', Kind.Fetch, { diff --git a/packages/core/src/tools/web-search/index.ts b/packages/core/src/tools/web-search/index.ts index f9962b52..b9aa83c5 100644 --- a/packages/core/src/tools/web-search/index.ts +++ b/packages/core/src/tools/web-search/index.ts @@ -30,6 +30,7 @@ import type { WebSearchProviderConfig, DashScopeProviderConfig, } from './types.js'; +import { ToolNames, ToolDisplayNames } from '../tool-names.js'; class WebSearchToolInvocation extends BaseToolInvocation< WebSearchToolParams, @@ -274,12 +275,12 @@ export class WebSearchTool extends BaseDeclarativeTool< WebSearchToolParams, WebSearchToolResult > { - static readonly Name: string = 'web_search'; + static readonly Name: string = ToolNames.WEB_SEARCH; constructor(private readonly config: Config) { super( WebSearchTool.Name, - 'WebSearch', + ToolDisplayNames.WEB_SEARCH, '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, { diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 560c77e5..c13c9553 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -27,7 +27,7 @@ import { ToolErrorType } from './tool-error.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; -import { ToolNames } from './tool-names.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; import type { ModifiableDeclarativeTool, ModifyContext, @@ -361,7 +361,7 @@ export class WriteFileTool constructor(private readonly config: Config) { super( WriteFileTool.Name, - 'WriteFile', + ToolDisplayNames.WRITE_FILE, `Writes content to a specified file in the local filesystem. The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, diff --git a/packages/core/src/utils/editHelper.test.ts b/packages/core/src/utils/editHelper.test.ts new file mode 100644 index 00000000..79fe78f6 --- /dev/null +++ b/packages/core/src/utils/editHelper.test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + countOccurrences, + maybeAugmentOldStringForDeletion, + normalizeEditStrings, +} from './editHelper.js'; + +describe('normalizeEditStrings', () => { + const file = `const one = 1; +const two = 2; +`; + + it('returns literal matches unchanged and trims new_string trailing whitespace', () => { + const result = normalizeEditStrings( + file, + 'const two = 2;', + ' const two = 42; ', + ); + expect(result).toEqual({ + oldString: 'const two = 2;', + newString: ' const two = 42;', + }); + }); + + it('normalizes smart quotes to match on-disk text', () => { + const result = normalizeEditStrings( + "const greeting = 'Don't';\n", + 'const greeting = ‘Don’t’;', + 'const greeting = “Hello”; ', + ); + expect(result).toEqual({ + oldString: "const greeting = 'Don't';", + newString: 'const greeting = “Hello”;', + }); + }); + + it('falls back to original strings when no match is found', () => { + const result = normalizeEditStrings(file, 'missing text', 'replacement'); + expect(result).toEqual({ + oldString: 'missing text', + newString: 'replacement', + }); + }); + + it('still trims new_string when editing a brand-new file', () => { + const result = normalizeEditStrings(null, '', 'new file contents '); + expect(result).toEqual({ + oldString: '', + newString: 'new file contents', + }); + }); + + it('matches unicode dash variants', () => { + const result = normalizeEditStrings( + 'const range = "1-2";\n', + 'const range = "1\u20132";', + 'const range = "3\u20135"; ', + ); + expect(result).toEqual({ + oldString: 'const range = "1-2";', + newString: 'const range = "3\u20135";', + }); + }); + + it('matches when trailing whitespace differs only at line ends', () => { + const result = normalizeEditStrings( + 'value = 1;\n', + 'value = 1; \n', + 'value = 2; \n', + ); + expect(result).toEqual({ + oldString: 'value = 1;\n', + newString: 'value = 2;\n', + }); + }); + + it('treats non-breaking spaces as regular spaces', () => { + const result = normalizeEditStrings( + 'const label = "hello world";\n', + 'const label = "hello\u00a0world";', + 'const label = "hi\u00a0world";', + ); + expect(result).toEqual({ + oldString: 'const label = "hello world";', + newString: 'const label = "hi\u00a0world";', + }); + }); + + it('drops trailing newline from new content when the file lacks it', () => { + const result = normalizeEditStrings( + 'console.log("hi")', + 'console.log("hi")\n', + 'console.log("bye")\n', + ); + expect(result).toEqual({ + oldString: 'console.log("hi")', + newString: 'console.log("bye")', + }); + }); +}); + +describe('countOccurrences', () => { + it('returns zero when substring empty or missing', () => { + expect(countOccurrences('abc', '')).toBe(0); + expect(countOccurrences('abc', 'z')).toBe(0); + }); + + it('counts non-overlapping occurrences', () => { + expect(countOccurrences('aaaa', 'aa')).toBe(2); + }); +}); + +describe('maybeAugmentOldStringForDeletion', () => { + const file = 'console.log("hi")\nconsole.log("bye")\n'; + + it('appends newline when deleting text followed by newline', () => { + expect( + maybeAugmentOldStringForDeletion(file, 'console.log("hi")', ''), + ).toBe('console.log("hi")\n'); + }); + + it('leaves strings untouched when not deleting', () => { + expect( + maybeAugmentOldStringForDeletion( + file, + 'console.log("hi")', + 'replacement', + ), + ).toBe('console.log("hi")'); + }); + + it('does not append newline when file lacks the variant', () => { + expect( + maybeAugmentOldStringForDeletion( + 'console.log("hi")', + 'console.log("hi")', + '', + ), + ).toBe('console.log("hi")'); + }); + + it('no-ops when the old string already ends with a newline', () => { + expect( + maybeAugmentOldStringForDeletion(file, 'console.log("bye")\n', ''), + ).toBe('console.log("bye")\n'); + }); +}); diff --git a/packages/core/src/utils/editHelper.ts b/packages/core/src/utils/editHelper.ts new file mode 100644 index 00000000..6b4a388d --- /dev/null +++ b/packages/core/src/utils/editHelper.ts @@ -0,0 +1,499 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Helpers for reconciling LLM-proposed edits with on-disk text. + * + * The normalization pipeline intentionally stays deterministic: we first try + * literal substring matches, then gradually relax comparison rules (smart + * quotes, em-dashes, trailing whitespace, etc.) until we either locate the + * exact slice from the file or conclude the edit cannot be applied. + */ + +/* -------------------------------------------------------------------------- */ +/* Character-level normalization */ +/* -------------------------------------------------------------------------- */ + +const UNICODE_EQUIVALENT_MAP: Record = { + // Hyphen variations → ASCII hyphen-minus. + '\u2010': '-', + '\u2011': '-', + '\u2012': '-', + '\u2013': '-', + '\u2014': '-', + '\u2015': '-', + '\u2212': '-', + // Curly single quotes → straight apostrophe. + '\u2018': "'", + '\u2019': "'", + '\u201A': "'", + '\u201B': "'", + // Curly double quotes → straight double quote. + '\u201C': '"', + '\u201D': '"', + '\u201E': '"', + '\u201F': '"', + // Whitespace variants → normal space. + '\u00A0': ' ', + '\u2002': ' ', + '\u2003': ' ', + '\u2004': ' ', + '\u2005': ' ', + '\u2006': ' ', + '\u2007': ' ', + '\u2008': ' ', + '\u2009': ' ', + '\u200A': ' ', + '\u202F': ' ', + '\u205F': ' ', + '\u3000': ' ', +}; + +function normalizeBasicCharacters(text: string): string { + if (text === '') { + return text; + } + + let normalized = ''; + for (const char of text) { + normalized += UNICODE_EQUIVALENT_MAP[char] ?? char; + } + return normalized; +} + +/** + * Removes trailing whitespace from each line while keeping the original newline + * separators intact. + */ +function stripTrailingWhitespacePreserveNewlines(text: string): string { + const pieces = text.split(/(\r\n|\n|\r)/); + let result = ''; + + for (let i = 0; i < pieces.length; i++) { + const segment = pieces[i]; + if (segment === undefined) { + continue; + } + + if (i % 2 === 0) { + result += segment.trimEnd(); + } else { + result += segment; + } + } + + return result; +} + +/* -------------------------------------------------------------------------- */ +/* Line-based search helpers */ +/* -------------------------------------------------------------------------- */ + +interface MatchedSliceResult { + slice: string; + removedTrailingFinalEmptyLine: boolean; +} + +/** + * Comparison passes become progressively more forgiving, making it possible to + * match when only trailing whitespace differs. Leading whitespace (indentation) + * is always preserved to avoid matching at incorrect scope levels. + */ +const LINE_COMPARISON_PASSES: Array<(value: string) => string> = [ + (value) => value, + (value) => value.trimEnd(), +]; + +function normalizeLineForComparison(value: string): string { + return normalizeBasicCharacters(value).trimEnd(); +} + +/** + * Finds the first index where {@link pattern} appears within {@link lines} once + * both sequences are transformed in the same way. + */ +function seekSequenceWithTransform( + lines: string[], + pattern: string[], + transform: (value: string) => string, +): number | null { + if (pattern.length === 0) { + return 0; + } + + if (pattern.length > lines.length) { + return null; + } + + outer: for (let i = 0; i <= lines.length - pattern.length; i++) { + for (let p = 0; p < pattern.length; p++) { + if (transform(lines[i + p]) !== transform(pattern[p])) { + continue outer; + } + } + return i; + } + + return null; +} + +function buildLineIndex(text: string): { + lines: string[]; + offsets: number[]; +} { + const lines = text.split('\n'); + const offsets = new Array(lines.length + 1); + let cursor = 0; + + for (let i = 0; i < lines.length; i++) { + offsets[i] = cursor; + cursor += lines[i].length; + if (i < lines.length - 1) { + cursor += 1; // Account for the newline that split() removed. + } + } + offsets[lines.length] = text.length; + + return { lines, offsets }; +} + +/** + * Reconstructs the original characters for the matched lines, optionally + * preserving the newline that follows the final line. + */ +function sliceFromLines( + text: string, + offsets: number[], + lines: string[], + startLine: number, + lineCount: number, + includeTrailingNewline: boolean, +): string { + if (lineCount === 0) { + return includeTrailingNewline ? '\n' : ''; + } + + const startIndex = offsets[startLine] ?? 0; + const lastLineIndex = startLine + lineCount - 1; + const lastLineStart = offsets[lastLineIndex] ?? 0; + let endIndex = lastLineStart + (lines[lastLineIndex]?.length ?? 0); + + if (includeTrailingNewline) { + const nextLineStart = offsets[startLine + lineCount]; + if (nextLineStart !== undefined) { + endIndex = nextLineStart; + } else if (text.endsWith('\n')) { + endIndex = text.length; + } + } + + return text.slice(startIndex, endIndex); +} + +function findLineBasedMatch( + haystack: string, + needle: string, +): MatchedSliceResult | null { + const { lines, offsets } = buildLineIndex(haystack); + const patternLines = needle.split('\n'); + const endsWithNewline = needle.endsWith('\n'); + + if (patternLines.length === 0) { + return null; + } + + const attemptMatch = (candidate: string[]): number | null => { + for (const pass of LINE_COMPARISON_PASSES) { + const idx = seekSequenceWithTransform(lines, candidate, pass); + if (idx !== null) { + return idx; + } + } + return seekSequenceWithTransform( + lines, + candidate, + normalizeLineForComparison, + ); + }; + + let matchIndex = attemptMatch(patternLines); + if (matchIndex !== null) { + return { + slice: sliceFromLines( + haystack, + offsets, + lines, + matchIndex, + patternLines.length, + endsWithNewline, + ), + removedTrailingFinalEmptyLine: false, + }; + } + + if (patternLines.at(-1) === '') { + const trimmedPattern = patternLines.slice(0, -1); + if (trimmedPattern.length === 0) { + return null; + } + matchIndex = attemptMatch(trimmedPattern); + if (matchIndex !== null) { + return { + slice: sliceFromLines( + haystack, + offsets, + lines, + matchIndex, + trimmedPattern.length, + false, + ), + removedTrailingFinalEmptyLine: true, + }; + } + } + + return null; +} + +/* -------------------------------------------------------------------------- */ +/* Slice discovery */ +/* -------------------------------------------------------------------------- */ + +function findMatchedSlice( + haystack: string, + needle: string, +): MatchedSliceResult | null { + if (needle === '') { + return null; + } + + const literalIndex = haystack.indexOf(needle); + if (literalIndex !== -1) { + return { + slice: haystack.slice(literalIndex, literalIndex + needle.length), + removedTrailingFinalEmptyLine: false, + }; + } + + const normalizedHaystack = normalizeBasicCharacters(haystack); + const normalizedNeedleChars = normalizeBasicCharacters(needle); + const normalizedIndex = normalizedHaystack.indexOf(normalizedNeedleChars); + if (normalizedIndex !== -1) { + return { + slice: haystack.slice(normalizedIndex, normalizedIndex + needle.length), + removedTrailingFinalEmptyLine: false, + }; + } + + return findLineBasedMatch(haystack, needle); +} + +/** + * Returns the literal slice from {@link haystack} that best corresponds to the + * provided {@link needle}, or {@code null} when no match is found. + */ +/* -------------------------------------------------------------------------- */ +/* Replacement helpers */ +/* -------------------------------------------------------------------------- */ + +function removeTrailingNewline(text: string): string { + if (text.endsWith('\r\n')) { + return text.slice(0, -2); + } + if (text.endsWith('\n') || text.endsWith('\r')) { + return text.slice(0, -1); + } + return text; +} + +function adjustNewStringForTrailingLine( + newString: string, + removedTrailingLine: boolean, +): string { + return removedTrailingLine ? removeTrailingNewline(newString) : newString; +} + +export interface NormalizedEditStrings { + oldString: string; + newString: string; +} + +/** + * Runs the core normalization pipeline: + * 1. Strip trailing whitespace copied from numbered output. + * 2. Attempt to find the literal text inside {@link fileContent}. + * 3. If found through a relaxed match (smart quotes, line trims, etc.), + * return the canonical slice from disk so later replacements operate on + * exact bytes. + */ +export function normalizeEditStrings( + fileContent: string | null, + oldString: string, + newString: string, +): NormalizedEditStrings { + const trimmedNewString = stripTrailingWhitespacePreserveNewlines(newString); + + if (fileContent === null || oldString === '') { + return { + oldString, + newString: trimmedNewString, + }; + } + + const canonicalOriginal = findMatchedSlice(fileContent, oldString); + if (canonicalOriginal !== null) { + return { + oldString: canonicalOriginal.slice, + newString: adjustNewStringForTrailingLine( + trimmedNewString, + canonicalOriginal.removedTrailingFinalEmptyLine, + ), + }; + } + + return { + oldString, + newString: trimmedNewString, + }; +} + +/** + * When deleting text and the on-disk content contains the same substring with a + * trailing newline, automatically consume that newline so the removal does not + * leave a blank line behind. + */ +export function maybeAugmentOldStringForDeletion( + fileContent: string | null, + oldString: string, + newString: string, +): string { + if ( + fileContent === null || + oldString === '' || + newString !== '' || + oldString.endsWith('\n') + ) { + return oldString; + } + + const candidate = `${oldString}\n`; + return fileContent.includes(candidate) ? candidate : oldString; +} + +/** + * Counts the number of non-overlapping occurrences of {@link substr} inside + * {@link source}. Returns 0 when the substring is empty. + */ +export function countOccurrences(source: string, substr: string): number { + if (substr === '') { + return 0; + } + + let count = 0; + let index = source.indexOf(substr); + while (index !== -1) { + count++; + index = source.indexOf(substr, index + substr.length); + } + return count; +} + +/** + * Result from extracting a snippet showing the edited region. + */ +export interface EditSnippetResult { + /** Starting line number (1-indexed) of the snippet */ + startLine: number; + /** Ending line number (1-indexed) of the snippet */ + endLine: number; + /** Total number of lines in the new content */ + totalLines: number; + /** The snippet content (subset of lines from newContent) */ + content: string; +} + +const SNIPPET_CONTEXT_LINES = 4; +const SNIPPET_MAX_LINES = 1000; + +/** + * Extracts a snippet from the edited file showing the changed region with + * surrounding context. This compares the old and new content line-by-line + * from both ends to locate the changed region. + * + * @param oldContent The original file content before the edit (null for new files) + * @param newContent The new file content after the edit + * @param contextLines Number of context lines to show before and after the change + * @returns Snippet information, or null if no meaningful snippet can be extracted + */ +export function extractEditSnippet( + oldContent: string | null, + newContent: string, +): EditSnippetResult | null { + const newLines = newContent.split('\n'); + const totalLines = newLines.length; + + if (oldContent === null) { + return { + startLine: 1, + endLine: totalLines, + totalLines, + content: newContent, + }; + } + + // No changes case + if (oldContent === newContent || !newContent) { + return null; + } + + const oldLines = oldContent.split('\n'); + + // Find the first line that differs from the start + let firstDiffLine = 0; + const minLength = Math.min(oldLines.length, newLines.length); + + while (firstDiffLine < minLength) { + if (oldLines[firstDiffLine] !== newLines[firstDiffLine]) { + break; + } + firstDiffLine++; + } + + // Find the first line that differs from the end + let oldEndIndex = oldLines.length - 1; + let newEndIndex = newLines.length - 1; + + while (oldEndIndex >= firstDiffLine && newEndIndex >= firstDiffLine) { + if (oldLines[oldEndIndex] !== newLines[newEndIndex]) { + break; + } + oldEndIndex--; + newEndIndex--; + } + + // The changed region in the new content is from firstDiffLine to newEndIndex (inclusive) + // Convert to 1-indexed line numbers + const changeStart = firstDiffLine + 1; + const changeEnd = newEndIndex + 1; + + // If the change region is too large, don't generate a snippet + if (changeEnd - changeStart > SNIPPET_MAX_LINES) { + return null; + } + + // Calculate snippet bounds with context + const snippetStart = Math.max(1, changeStart - SNIPPET_CONTEXT_LINES); + const snippetEnd = Math.min(totalLines, changeEnd + SNIPPET_CONTEXT_LINES); + + const snippetLines = newLines.slice(snippetStart - 1, snippetEnd); + + return { + startLine: snippetStart, + endLine: snippetEnd, + totalLines, + content: snippetLines.join('\n'), + }; +} diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index acc9e1a1..dd3202b8 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -72,6 +72,7 @@ describe('editor utils', () => { { editor: 'neovim', commands: ['nvim'], win32Commands: ['nvim'] }, { editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] }, { editor: 'emacs', commands: ['emacs'], win32Commands: ['emacs.exe'] }, + { editor: 'trae', commands: ['trae'], win32Commands: ['trae'] }, ]; for (const { editor, commands, win32Commands } of testCases) { @@ -171,6 +172,7 @@ describe('editor utils', () => { }, { editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] }, { editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] }, + { editor: 'trae', commands: ['trae'], win32Commands: ['trae'] }, ]; for (const { editor, commands, win32Commands } of guiEditors) { @@ -321,6 +323,7 @@ describe('editor utils', () => { 'windsurf', 'cursor', 'zed', + 'trae', ]; for (const editor of guiEditors) { @@ -430,6 +433,7 @@ describe('editor utils', () => { 'windsurf', 'cursor', 'zed', + 'trae', ]; for (const editor of guiEditors) { it(`should not call onEditorClose for ${editor}`, async () => { @@ -481,6 +485,7 @@ describe('editor utils', () => { 'windsurf', 'cursor', 'zed', + 'trae', ]; for (const editor of guiEditors) { it(`should not allow ${editor} in sandbox mode`, () => { diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 1023abe4..b6328925 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -14,7 +14,8 @@ export type EditorType = | 'vim' | 'neovim' | 'zed' - | 'emacs'; + | 'emacs' + | 'trae'; function isValidEditorType(editor: string): editor is EditorType { return [ @@ -26,6 +27,7 @@ function isValidEditorType(editor: string): editor is EditorType { 'neovim', 'zed', 'emacs', + 'trae', ].includes(editor); } @@ -62,6 +64,7 @@ const editorCommands: Record< neovim: { win32: ['nvim'], default: ['nvim'] }, zed: { win32: ['zed'], default: ['zed', 'zeditor'] }, emacs: { win32: ['emacs.exe'], default: ['emacs'] }, + trae: { win32: ['trae'], default: ['trae'] }, }; export function checkHasEditorType(editor: EditorType): boolean { @@ -73,7 +76,9 @@ export function checkHasEditorType(editor: EditorType): boolean { export function allowEditorTypeInSandbox(editor: EditorType): boolean { const notUsingSandbox = !process.env['SANDBOX']; - if (['vscode', 'vscodium', 'windsurf', 'cursor', 'zed'].includes(editor)) { + if ( + ['vscode', 'vscodium', 'windsurf', 'cursor', 'zed', 'trae'].includes(editor) + ) { return notUsingSandbox; } // For terminal-based editors like vim and emacs, allow in sandbox. @@ -115,6 +120,7 @@ export function getDiffCommand( case 'windsurf': case 'cursor': case 'zed': + case 'trae': return { command, args: ['--wait', '--diff', oldPath, newPath] }; case 'vim': case 'neovim': diff --git a/packages/core/src/utils/environmentContext.test.ts b/packages/core/src/utils/environmentContext.test.ts index aa436c6e..944e0906 100644 --- a/packages/core/src/utils/environmentContext.test.ts +++ b/packages/core/src/utils/environmentContext.test.ts @@ -13,9 +13,11 @@ import { afterEach, type Mock, } from 'vitest'; +import type { Content } from '@google/genai'; import { getEnvironmentContext, getDirectoryContextString, + getInitialChatHistory, } from './environmentContext.js'; import type { Config } from '../config/config.js'; import { getFolderStructure } from './getFolderStructure.js'; @@ -213,3 +215,102 @@ describe('getEnvironmentContext', () => { expect(parts[1].text).toBe('\n--- Error reading full file context ---'); }); }); + +describe('getInitialChatHistory', () => { + let mockConfig: Partial; + + beforeEach(() => { + vi.mocked(getFolderStructure).mockResolvedValue('Mock Folder Structure'); + mockConfig = { + getSkipStartupContext: vi.fn().mockReturnValue(false), + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue(['/test/dir']), + }), + getFileService: vi.fn(), + getFullContext: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('includes startup context when skipStartupContext is false', async () => { + const history = await getInitialChatHistory(mockConfig as Config); + + expect(mockConfig.getSkipStartupContext).toHaveBeenCalled(); + expect(history).toHaveLength(2); + expect(history).toEqual([ + expect.objectContaining({ + role: 'user', + parts: [ + expect.objectContaining({ + text: expect.stringContaining( + "I'm currently working in the directory", + ), + }), + ], + }), + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the context!' }], + }, + ]); + }); + + it('returns only extra history when skipStartupContext is true', async () => { + mockConfig.getSkipStartupContext = vi.fn().mockReturnValue(true); + mockConfig.getWorkspaceContext = vi.fn(() => { + throw new Error( + 'getWorkspaceContext should not be called when skipping startup context', + ); + }); + mockConfig.getFullContext = vi.fn(() => { + throw new Error( + 'getFullContext should not be called when skipping startup context', + ); + }); + mockConfig.getToolRegistry = vi.fn(() => { + throw new Error( + 'getToolRegistry should not be called when skipping startup context', + ); + }); + const extraHistory: Content[] = [ + { role: 'user', parts: [{ text: 'custom context' }] }, + ]; + + const history = await getInitialChatHistory( + mockConfig as Config, + extraHistory, + ); + + expect(mockConfig.getSkipStartupContext).toHaveBeenCalled(); + expect(history).toEqual(extraHistory); + expect(history).not.toBe(extraHistory); + }); + + it('returns empty history when skipping startup context without extras', async () => { + mockConfig.getSkipStartupContext = vi.fn().mockReturnValue(true); + mockConfig.getWorkspaceContext = vi.fn(() => { + throw new Error( + 'getWorkspaceContext should not be called when skipping startup context', + ); + }); + mockConfig.getFullContext = vi.fn(() => { + throw new Error( + 'getFullContext should not be called when skipping startup context', + ); + }); + mockConfig.getToolRegistry = vi.fn(() => { + throw new Error( + 'getToolRegistry should not be called when skipping startup context', + ); + }); + + const history = await getInitialChatHistory(mockConfig as Config); + + expect(history).toEqual([]); + }); +}); diff --git a/packages/core/src/utils/environmentContext.ts b/packages/core/src/utils/environmentContext.ts index 48840734..2bbe12dd 100644 --- a/packages/core/src/utils/environmentContext.ts +++ b/packages/core/src/utils/environmentContext.ts @@ -112,6 +112,10 @@ export async function getInitialChatHistory( config: Config, extraHistory?: Content[], ): Promise { + if (config.getSkipStartupContext()) { + return extraHistory ? [...extraHistory] : []; + } + const envParts = await getEnvironmentContext(config); const envContextString = envParts.map((part) => part.text || '').join('\n\n'); diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index c3500cdd..92af55e4 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -30,7 +30,7 @@ import { readFileWithEncoding, fileExists, } from './fileUtils.js'; -import { StandardFileSystemService } from '../services/fileSystemService.js'; +import type { Config } from '../config/config.js'; vi.mock('mime/lite', () => ({ default: { getType: vi.fn() }, @@ -50,6 +50,12 @@ describe('fileUtils', () => { let nonexistentFilePath: string; let directoryPath: string; + const mockConfig = { + getTruncateToolOutputThreshold: () => 2500, + getTruncateToolOutputLines: () => 500, + getTargetDir: () => tempRootDir, + } as unknown as Config; + beforeEach(() => { vi.resetAllMocks(); // Reset all mocks, including mime.getType @@ -664,8 +670,7 @@ describe('fileUtils', () => { actualNodeFs.writeFileSync(testTextFilePath, content); const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.llmContent).toBe(content); expect(result.returnDisplay).toBe(''); @@ -675,8 +680,7 @@ describe('fileUtils', () => { it('should handle file not found', async () => { const result = await processSingleFileContent( nonexistentFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.error).toContain('File not found'); expect(result.returnDisplay).toContain('File not found'); @@ -689,8 +693,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.error).toContain('Simulated read error'); expect(result.returnDisplay).toContain('Simulated read error'); @@ -704,8 +707,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testImageFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.error).toContain('Simulated image read error'); expect(result.returnDisplay).toContain('Simulated image read error'); @@ -717,8 +719,7 @@ describe('fileUtils', () => { mockMimeGetType.mockReturnValue('image/png'); const result = await processSingleFileContent( testImageFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect( (result.llmContent as { inlineData: unknown }).inlineData, @@ -739,8 +740,7 @@ describe('fileUtils', () => { mockMimeGetType.mockReturnValue('application/pdf'); const result = await processSingleFileContent( testPdfFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect( (result.llmContent as { inlineData: unknown }).inlineData, @@ -768,8 +768,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testSvgFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.llmContent).toBe(svgContent); @@ -786,8 +785,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testBinaryFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.llmContent).toContain( 'Cannot display content of binary file', @@ -796,11 +794,7 @@ describe('fileUtils', () => { }); it('should handle path being a directory', async () => { - const result = await processSingleFileContent( - directoryPath, - tempRootDir, - new StandardFileSystemService(), - ); + const result = await processSingleFileContent(directoryPath, mockConfig); expect(result.error).toContain('Path is a directory'); expect(result.returnDisplay).toContain('Path is a directory'); }); @@ -811,8 +805,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, 5, 5, ); // Read lines 6-10 @@ -832,8 +825,7 @@ describe('fileUtils', () => { // Read from line 11 to 20. The start is not 0, so it's truncated. const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, 10, 10, ); @@ -852,8 +844,7 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, 0, 10, ); @@ -875,17 +866,16 @@ describe('fileUtils', () => { const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.llmContent).toContain('Short line'); expect(result.llmContent).toContain( longLine.substring(0, 2000) + '... [truncated]', ); - expect(result.llmContent).toContain('Another short line'); + expect(result.llmContent).not.toContain('Another short line'); expect(result.returnDisplay).toBe( - 'Read all 3 lines from test.txt (some lines were shortened)', + 'Read lines 1-2 of 3 from test.txt (truncated)', ); expect(result.isTruncated).toBe(true); }); @@ -897,8 +887,7 @@ describe('fileUtils', () => { // Read 5 lines, but there are 11 total const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, 0, 5, ); @@ -916,15 +905,14 @@ describe('fileUtils', () => { // Read all 11 lines, including the long one const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, 0, 11, ); expect(result.isTruncated).toBe(true); expect(result.returnDisplay).toBe( - 'Read all 11 lines from test.txt (some lines were shortened)', + 'Read lines 1-11 of 11 from test.txt (truncated)', ); }); @@ -942,14 +930,13 @@ describe('fileUtils', () => { // Read 10 lines out of 20, including the long line const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, 0, 10, ); expect(result.isTruncated).toBe(true); expect(result.returnDisplay).toBe( - 'Read lines 1-10 of 20 from test.txt (some lines were shortened)', + 'Read lines 1-5 of 20 from test.txt (truncated)', ); }); @@ -966,8 +953,7 @@ describe('fileUtils', () => { try { const result = await processSingleFileContent( testTextFilePath, - tempRootDir, - new StandardFileSystemService(), + mockConfig, ); expect(result.error).toContain('File size exceeds the 20MB limit'); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index b321ac54..940e9794 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -9,13 +9,9 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import type { PartUnion } from '@google/genai'; import mime from 'mime/lite'; -import type { FileSystemService } from '../services/fileSystemService.js'; import { ToolErrorType } from '../tools/tool-error.js'; import { BINARY_EXTENSIONS } from './ignorePatterns.js'; - -// Constants for text file processing -export const DEFAULT_MAX_LINES_TEXT_FILE = 2000; -const MAX_LINE_LENGTH_TEXT_FILE = 2000; +import type { Config } from '../config/config.js'; // Default values for encoding and separator format export const DEFAULT_ENCODING: BufferEncoding = 'utf-8'; @@ -306,18 +302,18 @@ export interface ProcessedFileReadResult { /** * Reads and processes a single file, handling text, images, and PDFs. * @param filePath Absolute path to the file. - * @param rootDirectory Absolute path to the project root for relative path display. + * @param config Config instance for truncation settings. * @param offset Optional offset for text files (0-based line number). * @param limit Optional limit for text files (number of lines to read). * @returns ProcessedFileReadResult object. */ export async function processSingleFileContent( filePath: string, - rootDirectory: string, - fileSystemService: FileSystemService, + config: Config, offset?: number, limit?: number, ): Promise { + const rootDirectory = config.getTargetDir(); try { if (!fs.existsSync(filePath)) { // Sync check is acceptable before async read @@ -379,45 +375,76 @@ export async function processSingleFileContent( case 'text': { // Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently const content = await readFileWithEncoding(filePath); - const lines = content.split('\n'); + const lines = content.split('\n').map((line) => line.trimEnd()); const originalLineCount = lines.length; const startLine = offset || 0; - const effectiveLimit = - limit === undefined ? DEFAULT_MAX_LINES_TEXT_FILE : limit; + const configLineLimit = config.getTruncateToolOutputLines(); + const configCharLimit = config.getTruncateToolOutputThreshold(); + const effectiveLimit = limit === undefined ? configLineLimit : limit; + // Ensure endLine does not exceed originalLineCount const endLine = Math.min(startLine + effectiveLimit, originalLineCount); // Ensure selectedLines doesn't try to slice beyond array bounds if startLine is too high const actualStartLine = Math.min(startLine, originalLineCount); const selectedLines = lines.slice(actualStartLine, endLine); - let linesWereTruncatedInLength = false; - const formattedLines = selectedLines.map((line) => { - if (line.length > MAX_LINE_LENGTH_TEXT_FILE) { - linesWereTruncatedInLength = true; - return ( - line.substring(0, MAX_LINE_LENGTH_TEXT_FILE) + '... [truncated]' - ); + // Apply character limit truncation + let llmContent = ''; + let contentLengthTruncated = false; + let linesIncluded = 0; + + if (Number.isFinite(configCharLimit)) { + const formattedLines: string[] = []; + let currentLength = 0; + + for (const line of selectedLines) { + const sep = linesIncluded > 0 ? 1 : 0; // newline separator + linesIncluded++; + + const projectedLength = currentLength + line.length + sep; + if (projectedLength <= configCharLimit) { + formattedLines.push(line); + currentLength = projectedLength; + } else { + // Truncate the current line to fit + const remaining = Math.max( + configCharLimit - currentLength - sep, + 10, + ); + formattedLines.push( + line.substring(0, remaining) + '... [truncated]', + ); + contentLengthTruncated = true; + break; + } } - return line; - }); + + llmContent = formattedLines.join('\n'); + } else { + // No character limit, use all selected lines + llmContent = selectedLines.join('\n'); + linesIncluded = selectedLines.length; + } + + // Calculate actual end line shown + const actualEndLine = contentLengthTruncated + ? actualStartLine + linesIncluded + : endLine; const contentRangeTruncated = - startLine > 0 || endLine < originalLineCount; - const isTruncated = contentRangeTruncated || linesWereTruncatedInLength; - const llmContent = formattedLines.join('\n'); + startLine > 0 || actualEndLine < originalLineCount; + const isTruncated = contentRangeTruncated || contentLengthTruncated; // By default, return nothing to streamline the common case of a successful read_file. let returnDisplay = ''; - if (contentRangeTruncated) { + if (isTruncated) { returnDisplay = `Read lines ${ actualStartLine + 1 - }-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`; - if (linesWereTruncatedInLength) { - returnDisplay += ' (some lines were shortened)'; + }-${actualEndLine} of ${originalLineCount} from ${relativePathForDisplay}`; + if (contentLengthTruncated) { + returnDisplay += ' (truncated)'; } - } else if (linesWereTruncatedInLength) { - returnDisplay = `Read all ${originalLineCount} lines from ${relativePathForDisplay} (some lines were shortened)`; } return { @@ -425,7 +452,7 @@ export async function processSingleFileContent( returnDisplay, isTruncated, originalLineCount, - linesShown: [actualStartLine + 1, endLine], + linesShown: [actualStartLine + 1, actualEndLine], }; } case 'image': diff --git a/packages/core/src/utils/pathReader.test.ts b/packages/core/src/utils/pathReader.test.ts index 8a1bb52c..fd6ff224 100644 --- a/packages/core/src/utils/pathReader.test.ts +++ b/packages/core/src/utils/pathReader.test.ts @@ -29,6 +29,8 @@ const createMockConfig = ( getTargetDir: () => cwd, getFileSystemService: () => fileSystemService, getFileService: () => mockFileService, + getTruncateToolOutputThreshold: () => 2500, + getTruncateToolOutputLines: () => 500, } as unknown as Config; }; diff --git a/packages/core/src/utils/pathReader.ts b/packages/core/src/utils/pathReader.ts index bf60a1a1..37cbb629 100644 --- a/packages/core/src/utils/pathReader.ts +++ b/packages/core/src/utils/pathReader.ts @@ -83,11 +83,7 @@ export async function readPathFromWorkspace( for (const filePath of finalFiles) { const relativePathForDisplay = path.relative(absolutePath, filePath); allParts.push({ text: `--- ${relativePathForDisplay} ---\n` }); - const result = await processSingleFileContent( - filePath, - config.getTargetDir(), - config.getFileSystemService(), - ); + const result = await processSingleFileContent(filePath, config); allParts.push(result.llmContent); allParts.push({ text: '\n' }); // Add a newline for separation } @@ -108,11 +104,7 @@ export async function readPathFromWorkspace( } // It's a single file, process it directly. - const result = await processSingleFileContent( - absolutePath, - config.getTargetDir(), - config.getFileSystemService(), - ); + const result = await processSingleFileContent(absolutePath, config); return [result.llmContent]; } } diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 0e964672..6359ba81 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -4,8 +4,53 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { escapePath, unescapePath, isSubpath } from './paths.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { + escapePath, + resolvePath, + validatePath, + resolveAndValidatePath, + unescapePath, + isSubpath, +} from './paths.js'; +import type { Config } from '../config/config.js'; + +function createConfigStub({ + targetDir, + allowedDirectories, +}: { + targetDir: string; + allowedDirectories: string[]; +}): Config { + const resolvedTargetDir = path.resolve(targetDir); + const resolvedDirectories = allowedDirectories.map((dir) => + path.resolve(dir), + ); + + const workspaceContext = { + isPathWithinWorkspace(testPath: string) { + const resolvedPath = path.resolve(testPath); + return resolvedDirectories.some((dir) => { + const relative = path.relative(dir, resolvedPath); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); + }); + }, + getDirectories() { + return resolvedDirectories; + }, + }; + + return { + getTargetDir: () => resolvedTargetDir, + getWorkspaceContext: () => workspaceContext, + } as unknown as Config; +} describe('escapePath', () => { it('should escape spaces', () => { @@ -314,3 +359,240 @@ describe('isSubpath on Windows', () => { expect(isSubpath('Users\\Test\\file.txt', 'Users\\Test')).toBe(false); }); }); + +describe('resolvePath', () => { + it('resolves relative paths against the provided base directory', () => { + const result = resolvePath('/home/user/project', 'src/main.ts'); + expect(result).toBe(path.resolve('/home/user/project', 'src/main.ts')); + }); + + it('resolves relative paths against cwd when baseDir is undefined', () => { + const cwd = process.cwd(); + const result = resolvePath(undefined, 'src/main.ts'); + expect(result).toBe(path.resolve(cwd, 'src/main.ts')); + }); + + it('returns absolute paths unchanged', () => { + const absolutePath = '/absolute/path/to/file.ts'; + const result = resolvePath('/some/base', absolutePath); + expect(result).toBe(absolutePath); + }); + + it('expands tilde to home directory', () => { + const homeDir = os.homedir(); + const result = resolvePath(undefined, '~'); + expect(result).toBe(homeDir); + }); + + it('expands tilde-prefixed paths to home directory', () => { + const homeDir = os.homedir(); + const result = resolvePath(undefined, '~/documents/file.txt'); + expect(result).toBe(path.join(homeDir, 'documents/file.txt')); + }); + + it('uses baseDir when provided for relative paths', () => { + const baseDir = '/custom/base'; + const result = resolvePath(baseDir, './relative/path'); + expect(result).toBe(path.resolve(baseDir, './relative/path')); + }); + + it('handles tilde expansion regardless of baseDir', () => { + const homeDir = os.homedir(); + const result = resolvePath('/some/base', '~/file.txt'); + expect(result).toBe(path.join(homeDir, 'file.txt')); + }); + + it('handles dot paths correctly', () => { + const result = resolvePath('/base/dir', '.'); + expect(result).toBe(path.resolve('/base/dir', '.')); + }); + + it('handles parent directory references', () => { + const result = resolvePath('/base/dir/subdir', '..'); + expect(result).toBe(path.resolve('/base/dir/subdir', '..')); + }); +}); + +describe('validatePath', () => { + let workspaceRoot: string; + let config: Config; + + beforeAll(() => { + workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'validate-path-test-'), + ); + fs.mkdirSync(path.join(workspaceRoot, 'subdir')); + config = createConfigStub({ + targetDir: workspaceRoot, + allowedDirectories: [workspaceRoot], + }); + }); + + afterAll(() => { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + it('validates paths within workspace boundaries', () => { + const validPath = path.join(workspaceRoot, 'subdir'); + expect(() => validatePath(config, validPath)).not.toThrow(); + }); + + it('throws when path is outside workspace boundaries', () => { + const outsidePath = path.join(os.tmpdir(), 'outside'); + expect(() => validatePath(config, outsidePath)).toThrowError( + /Path is not within workspace/, + ); + }); + + it('throws when path does not exist', () => { + const nonExistentPath = path.join(workspaceRoot, 'nonexistent'); + expect(() => validatePath(config, nonExistentPath)).toThrowError( + /Path does not exist:/, + ); + }); + + it('throws when path is a file, not a directory (default behavior)', () => { + const filePath = path.join(workspaceRoot, 'test-file.txt'); + fs.writeFileSync(filePath, 'content'); + try { + expect(() => validatePath(config, filePath)).toThrowError( + /Path is not a directory/, + ); + } finally { + fs.rmSync(filePath); + } + }); + + it('allows files when allowFiles option is true', () => { + const filePath = path.join(workspaceRoot, 'test-file.txt'); + fs.writeFileSync(filePath, 'content'); + try { + expect(() => + validatePath(config, filePath, { allowFiles: true }), + ).not.toThrow(); + } finally { + fs.rmSync(filePath); + } + }); + + it('validates paths at workspace root', () => { + expect(() => validatePath(config, workspaceRoot)).not.toThrow(); + }); + + it('validates paths in allowed directories', () => { + const extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-extra-')); + try { + const configWithExtra = createConfigStub({ + targetDir: workspaceRoot, + allowedDirectories: [workspaceRoot, extraDir], + }); + expect(() => validatePath(configWithExtra, extraDir)).not.toThrow(); + } finally { + fs.rmSync(extraDir, { recursive: true, force: true }); + } + }); +}); + +describe('resolveAndValidatePath', () => { + let workspaceRoot: string; + let config: Config; + + beforeAll(() => { + workspaceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'resolve-and-validate-'), + ); + fs.mkdirSync(path.join(workspaceRoot, 'subdir')); + config = createConfigStub({ + targetDir: workspaceRoot, + allowedDirectories: [workspaceRoot], + }); + }); + + afterAll(() => { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + }); + + it('returns the target directory when no path is provided', () => { + expect(resolveAndValidatePath(config)).toBe(workspaceRoot); + }); + + it('resolves relative paths within the workspace', () => { + const expected = path.join(workspaceRoot, 'subdir'); + expect(resolveAndValidatePath(config, 'subdir')).toBe(expected); + }); + + it('allows absolute paths that are permitted by the workspace context', () => { + const extraDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'resolve-and-validate-extra-'), + ); + try { + const configWithExtra = createConfigStub({ + targetDir: workspaceRoot, + allowedDirectories: [workspaceRoot, extraDir], + }); + expect(resolveAndValidatePath(configWithExtra, extraDir)).toBe(extraDir); + } finally { + fs.rmSync(extraDir, { recursive: true, force: true }); + } + }); + + it('expands tilde-prefixed paths using the home directory', () => { + const fakeHome = fs.mkdtempSync( + path.join(os.tmpdir(), 'resolve-and-validate-home-'), + ); + const homeSubdir = path.join(fakeHome, 'project'); + fs.mkdirSync(homeSubdir); + + const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + try { + const configWithHome = createConfigStub({ + targetDir: workspaceRoot, + allowedDirectories: [workspaceRoot, fakeHome], + }); + expect(resolveAndValidatePath(configWithHome, '~/project')).toBe( + homeSubdir, + ); + expect(resolveAndValidatePath(configWithHome, '~')).toBe(fakeHome); + } finally { + homedirSpy.mockRestore(); + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); + + it('throws when the path resolves outside of the workspace', () => { + expect(() => resolveAndValidatePath(config, '../outside')).toThrowError( + /Path is not within workspace/, + ); + }); + + it('throws when the path does not exist', () => { + expect(() => resolveAndValidatePath(config, 'missing')).toThrowError( + /Path does not exist:/, + ); + }); + + it('throws when the path points to a file (default behavior)', () => { + const filePath = path.join(workspaceRoot, 'file.txt'); + fs.writeFileSync(filePath, 'content'); + try { + expect(() => resolveAndValidatePath(config, 'file.txt')).toThrowError( + `Path is not a directory: ${filePath}`, + ); + } finally { + fs.rmSync(filePath); + } + }); + + it('allows file paths when allowFiles option is true', () => { + const filePath = path.join(workspaceRoot, 'file.txt'); + fs.writeFileSync(filePath, 'content'); + try { + const result = resolveAndValidatePath(config, 'file.txt', { + allowFiles: true, + }); + expect(result).toBe(filePath); + } finally { + fs.rmSync(filePath); + } + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 3bf301c8..c5986c68 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -4,9 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import * as crypto from 'node:crypto'; +import type { Config } from '../config/config.js'; +import { isNodeError } from './errors.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -191,3 +194,93 @@ export function isSubpath(parentPath: string, childPath: string): boolean { !pathModule.isAbsolute(relative) ); } + +/** + * Resolves a path with tilde (~) expansion and relative path resolution. + * Handles tilde expansion for home directory and resolves relative paths + * against the provided base directory or current working directory. + * + * @param baseDir The base directory to resolve relative paths against (defaults to current working directory) + * @param relativePath The path to resolve (can be relative, absolute, or tilde-prefixed) + * @returns The resolved absolute path + */ +export function resolvePath( + baseDir: string | undefined = process.cwd(), + relativePath: string, +): string { + const homeDir = os.homedir(); + + if (relativePath === '~') { + return homeDir; + } else if (relativePath.startsWith('~/')) { + return path.join(homeDir, relativePath.slice(2)); + } else if (path.isAbsolute(relativePath)) { + return relativePath; + } else { + return path.resolve(baseDir, relativePath); + } +} + +export interface PathValidationOptions { + /** + * If true, allows both files and directories. If false (default), only allows directories. + */ + allowFiles?: boolean; +} + +/** + * Validates that a resolved path exists within the workspace boundaries. + * + * @param config The configuration object containing workspace context + * @param resolvedPath The absolute path to validate + * @param options Validation options + * @throws Error if the path is outside workspace boundaries, doesn't exist, or is not a directory (when allowFiles is false) + */ +export function validatePath( + config: Config, + resolvedPath: string, + options: PathValidationOptions = {}, +): void { + const { allowFiles = false } = options; + const workspaceContext = config.getWorkspaceContext(); + + if (!workspaceContext.isPathWithinWorkspace(resolvedPath)) { + throw new Error('Path is not within workspace'); + } + + try { + const stats = fs.statSync(resolvedPath); + if (!allowFiles && !stats.isDirectory()) { + throw new Error(`Path is not a directory: ${resolvedPath}`); + } + } catch (error: unknown) { + if (isNodeError(error) && error.code === 'ENOENT') { + throw new Error(`Path does not exist: ${resolvedPath}`); + } + throw error; + } +} + +/** + * Resolves a path relative to the workspace root and verifies that it exists + * within the workspace boundaries defined in the config. + * + * @param config The configuration object + * @param relativePath The relative path to resolve (optional, defaults to target directory) + * @param options Validation options (e.g., allowFiles to permit file paths) + */ +export function resolveAndValidatePath( + config: Config, + relativePath?: string, + options: PathValidationOptions = {}, +): string { + const targetDir = config.getTargetDir(); + + if (!relativePath) { + return targetDir; + } + + const resolvedPath = resolvePath(targetDir, relativePath); + validatePath(config, resolvedPath, options); + return resolvedPath; +} diff --git a/packages/core/src/utils/tool-utils.test.ts b/packages/core/src/utils/tool-utils.test.ts index 5527e186..e24585e0 100644 --- a/packages/core/src/utils/tool-utils.test.ts +++ b/packages/core/src/utils/tool-utils.test.ts @@ -5,9 +5,10 @@ */ import { expect, describe, it } from 'vitest'; -import { doesToolInvocationMatch } from './tool-utils.js'; +import { doesToolInvocationMatch, isToolEnabled } from './tool-utils.js'; import type { AnyToolInvocation, Config } from '../index.js'; import { ReadFileTool } from '../tools/read-file.js'; +import { ToolNames } from '../tools/tool-names.js'; describe('doesToolInvocationMatch', () => { it('should not match a partial command prefix', () => { @@ -92,3 +93,67 @@ describe('doesToolInvocationMatch', () => { }); }); }); + +describe('isToolEnabled', () => { + it('enables tool when coreTools is undefined and tool is not excluded', () => { + expect(isToolEnabled(ToolNames.SHELL, undefined, undefined)).toBe(true); + }); + + it('disables tool when excluded by canonical tool name', () => { + expect( + isToolEnabled(ToolNames.SHELL, undefined, ['run_shell_command']), + ).toBe(false); + }); + + it('enables tool when explicitly listed by display name', () => { + expect(isToolEnabled(ToolNames.SHELL, ['Shell'], undefined)).toBe(true); + }); + + it('enables tool when explicitly listed by class name', () => { + expect(isToolEnabled(ToolNames.SHELL, ['ShellTool'], undefined)).toBe(true); + }); + + it('supports class names with leading underscores', () => { + expect(isToolEnabled(ToolNames.SHELL, ['__ShellTool'], undefined)).toBe( + true, + ); + }); + + it('enables tool when coreTools contains a legacy tool name alias', () => { + expect( + isToolEnabled(ToolNames.GREP, ['search_file_content'], undefined), + ).toBe(true); + }); + + it('enables tool when coreTools contains a legacy display name alias', () => { + expect(isToolEnabled(ToolNames.GLOB, ['FindFiles'], undefined)).toBe(true); + }); + + it('enables tool when coreTools contains an argument-specific pattern', () => { + expect( + isToolEnabled(ToolNames.SHELL, ['Shell(git status)'], undefined), + ).toBe(true); + }); + + it('disables tool when not present in coreTools', () => { + expect(isToolEnabled(ToolNames.SHELL, ['Edit'], undefined)).toBe(false); + }); + + it('uses legacy display name aliases when excluding tools', () => { + expect(isToolEnabled(ToolNames.GREP, undefined, ['SearchFiles'])).toBe( + false, + ); + }); + + it('does not treat argument-specific exclusions as matches', () => { + expect( + isToolEnabled(ToolNames.SHELL, undefined, ['Shell(git status)']), + ).toBe(true); + }); + + it('considers excludeTools even when tool is explicitly enabled', () => { + expect(isToolEnabled(ToolNames.SHELL, ['Shell'], ['ShellTool'])).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/utils/tool-utils.ts b/packages/core/src/utils/tool-utils.ts index cd3053ff..fb6f52c7 100644 --- a/packages/core/src/utils/tool-utils.ts +++ b/packages/core/src/utils/tool-utils.ts @@ -6,6 +6,111 @@ import type { AnyDeclarativeTool, AnyToolInvocation } from '../index.js'; import { isTool } from '../index.js'; +import { + ToolNames, + ToolDisplayNames, + ToolNamesMigration, + ToolDisplayNamesMigration, +} from '../tools/tool-names.js'; + +export type ToolName = (typeof ToolNames)[keyof typeof ToolNames]; + +const normalizeIdentifier = (identifier: string): string => + identifier.trim().replace(/^_+/, ''); + +const toolNameKeys = Object.keys(ToolNames) as Array; + +const TOOL_ALIAS_MAP: Map> = (() => { + const map = new Map>(); + + const addAlias = (set: Set, alias?: string) => { + if (!alias) { + return; + } + set.add(normalizeIdentifier(alias)); + }; + + for (const key of toolNameKeys) { + const canonicalName = ToolNames[key]; + const displayName = ToolDisplayNames[key]; + const aliases = new Set(); + + addAlias(aliases, canonicalName); + addAlias(aliases, displayName); + addAlias(aliases, `${displayName}Tool`); + + for (const [legacyName, mappedName] of Object.entries(ToolNamesMigration)) { + if (mappedName === canonicalName) { + addAlias(aliases, legacyName); + } + } + + for (const [legacyDisplay, mappedDisplay] of Object.entries( + ToolDisplayNamesMigration, + )) { + if (mappedDisplay === displayName) { + addAlias(aliases, legacyDisplay); + } + } + + map.set(canonicalName, aliases); + } + + return map; +})(); + +const getAliasSetForTool = (toolName: ToolName): Set => { + const aliases = TOOL_ALIAS_MAP.get(toolName); + if (!aliases) { + return new Set([normalizeIdentifier(toolName)]); + } + return aliases; +}; + +const sanitizeExactIdentifier = (value: string): string => + normalizeIdentifier(value); + +const sanitizePatternIdentifier = (value: string): string => { + const openParenIndex = value.indexOf('('); + if (openParenIndex === -1) { + return normalizeIdentifier(value); + } + return normalizeIdentifier(value.slice(0, openParenIndex)); +}; + +const filterList = (list?: string[]): string[] => + (list ?? []).filter((entry): entry is string => + Boolean(entry && entry.trim()), + ); + +export function isToolEnabled( + toolName: ToolName, + coreTools?: string[], + excludeTools?: string[], +): boolean { + const aliasSet = getAliasSetForTool(toolName); + const matchesIdentifier = (value: string): boolean => + aliasSet.has(sanitizeExactIdentifier(value)); + const matchesIdentifierWithArgs = (value: string): boolean => + aliasSet.has(sanitizePatternIdentifier(value)); + + const filteredCore = filterList(coreTools); + const filteredExclude = filterList(excludeTools); + + if (filteredCore.length === 0) { + return !filteredExclude.some((entry) => matchesIdentifier(entry)); + } + + const isExplicitlyEnabled = filteredCore.some( + (entry) => matchesIdentifier(entry) || matchesIdentifierWithArgs(entry), + ); + + if (!isExplicitlyEnabled) { + return false; + } + + return !filteredExclude.some((entry) => matchesIdentifier(entry)); +} const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 089b883c..512ada66 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.1.5", + "version": "0.2.2", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index f76d4113..dd86c816 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.1.5", + "version": "0.2.2", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": {