Compare commits

..

7 Commits

Author SHA1 Message Date
yiliang114
d9328fa478 feat: 统一LSP工具并扩展操作支持
- 创建统一的LSP工具,整合了之前的多个分散LSP工具
- 增加对更多LSP操作的支持,包括hover、documentSymbol、goToImplementation等
- 扩展LSP类型定义,支持Call Hierarchy等高级功能
- 更新配置和测试文件以适配新的LSP工具架构
- 保持向后兼容性,同时引入新工具名称映射

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

此更改是LSP工具重构计划的一部分,旨在提供更统一和功能完备的LSP集成体验。
2026-01-18 19:34:17 +08:00
yiliang114
a14d1e27bb Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/support-lsp 2026-01-18 13:49:32 +08:00
yiliang114
c4e6c096dc feat(cli): improve LSP service implementation with type safety and iteration fixes
- Fix iteration over Map and Set collections by using Array.from() to avoid
  potential modification during iteration issues
- Add proper type casting for test mocks to ensure type safety
- Add null checks and type guards for LSP reference and symbol processing
- Improve type annotations for LSP server status and configuration objects
- Update path validation to use workspace root instead of config.cwd

These changes improve the robustness and type safety of the LSP service implementation.
2026-01-07 19:59:19 +08:00
yiliang114
4857f2f803 Merge branch 'feat/support-lsp-1' into feat/support-lsp 2026-01-07 15:22:34 +08:00
yiliang114
5a907c3415 wip(cli): support lsp 2026-01-07 15:21:33 +08:00
yiliang114
d1d215b82e wip(cli): support lsp 2026-01-05 10:18:24 +08:00
yiliang114
a67a8d0277 wip(cli): support lsp 2026-01-05 01:42:05 +08:00
85 changed files with 7829 additions and 2258 deletions

View File

@@ -13,5 +13,10 @@
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"vitest.disableWorkspaceWarning": true
"vitest.disableWorkspaceWarning": true,
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server"],
"excluded": ["gopls"]
}
}

147
cclsp-integration-plan.md Normal file
View File

@@ -0,0 +1,147 @@
# Qwen Code CLI LSP 集成实现方案分析
## 1. 项目概述
本方案旨在将 LSPLanguage Server Protocol能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。
## 2. 技术方案对比
### 2.1 Piebald-AI/claude-code-lsps 方案
- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由
- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装
- **安全**: LSP 子进程以用户权限运行,无内置信任门控
- **功能覆盖**: 可以暴露完整的 LSP 表面hover、诊断、代码操作、重命名等
### 2.2 原生 LSP 客户端方案(推荐方案)
- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接
- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置
- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示)
- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等)
### 2.3 cclsp + MCP 方案(备选)
- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接
- **用户配置**: 需要 MCP 配置
- **安全**: 通过 MCP 安全控制
- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具
## 3. 原生 LSP 集成详细计划
### 3.1 方案选择
- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验
- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接
- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略
### 3.2 实现步骤
#### 3.2.1 创建原生 LSP 服务
`packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理:
- 工作区语言检测
- 自动发现和启动语言服务器
- 与现有文档/编辑模型同步
- LSP 能力直接暴露给代理
#### 3.2.2 配置支持
- 支持内置预设配置(常见语言服务器)
- 支持用户自定义 `.lsp.json` 配置文件
- 与 MCP 配置共存,共享信任控制
#### 3.2.3 集成启动流程
-`packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成
- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制
- 处理沙箱预检和主运行的重复调用问题
#### 3.2.4 功能标志配置
-`packages/cli/src/config/settingsSchema.ts` 中添加新的设置项
- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能
- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置
#### 3.2.5 安全控制
- 与 MCP 共享相同的安全控制机制
- 在信任工作区中自动启用,在非信任工作区中提示用户
- 实现路径允许列表和进程启动确认
#### 3.2.6 错误处理与用户通知
- 检测缺失的语言服务器并提供安装命令
- 通过现有 MCP 状态 UI 显示错误信息
- 实现重试/退避机制,检测沙箱环境并抑制自动启动
### 3.3 需要确认的不确定项
1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调
2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP
3. **功能开关设计**开关应该是全局级别的LSP 和 MCP 可独立启用/禁用
4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑
5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步
6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项
7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制
### 3.4 安全考虑
- 与 MCP 共享相同的安全控制模型
- 仅在受信任工作区中启用自动 LSP 功能
- 提供用户确认机制用于启动新的 LSP 服务器
- 防止路径劫持,使用安全的路径解析
### 3.5 高级 LSP 功能支持
- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等
- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置
- **性能优化**: 优化 LSP 服务器启动时间和内存使用
### 3.6 用户体验
- 提供安装提示而非自动安装
- 在统一的状态界面显示 LSP 和 MCP 服务器状态
- 提供独立开关让用户控制 LSP 和 MCP 功能
- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息
## 4. 实施总结
### 4.1 已完成的工作
1. **NativeLspService 类**创建了核心服务类包含语言检测、配置合并、LSP 连接管理等功能
2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理
3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测
4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并
5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证
6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点
### 4.2 关键组件
#### 4.2.1 LspConnectionFactory
- 使用 `vscode-jsonrpc``vscode-languageserver-protocol` 实现 LSP 连接
- 支持 stdio 传输方式,可以扩展支持 TCP 传输
- 提供连接创建、初始化和关闭的完整生命周期管理
#### 4.2.2 NativeLspService
- **语言检测**:扫描项目文件和配置文件来识别编程语言
- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置
- **LSP 服务器管理**:启动、停止和状态管理
- **安全控制**:与 MCP 共享的信任和确认机制
#### 4.2.3 配置架构
- **内置预设**:为常见语言提供默认 LSP 服务器配置
- **用户配置**:支持 `.lsp.json` 文件格式
- **Claude 兼容**:可导入 Claude Code 的 LSP 配置
### 4.3 依赖管理
- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信
- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递
- 使用 `vscode-languageserver-textdocument` 管理文档版本
### 4.4 安全特性
- 工作区信任检查
- 用户确认机制(对于非信任工作区)
- 命令存在性验证
- 路径安全性检查
## 5. 总结
原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。
该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。

View File

@@ -5,13 +5,11 @@ Qwen Code supports two authentication methods. Pick the one that matches how you
- **Qwen OAuth (recommended)**: sign in with your `qwen.ai` account in a browser.
- **OpenAI-compatible API**: use an API key (OpenAI or any OpenAI-compatible provider / endpoint).
![](https://img.alicdn.com/imgextra/i2/O1CN01IxI1bt1sNO543AVTT_!!6000000005754-0-tps-1958-822.jpg)
## Option 1: Qwen OAuth (recommended & free) 👍
Use this if you want the simplest setup and you're using Qwen models.
Use this if you want the simplest setup and youre using Qwen models.
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually won't need to log in again.
- **How it works**: on first start, Qwen Code opens a browser login page. After you finish, credentials are cached locally so you usually wont need to log in again.
- **Requirements**: a `qwen.ai` account + internet access (at least for the first login).
- **Benefits**: no API key management, automatic credential refresh.
- **Cost & quota**: free, with a quota of **60 requests/minute** and **2,000 requests/day**.
@@ -26,54 +24,15 @@ qwen
Use this if you want to use OpenAI models or any provider that exposes an OpenAI-compatible API (e.g. OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud Bailian, or a self-hosted compatible endpoint).
### Recommended: Coding Plan (subscription-based) 🚀
### Quick start (interactive, recommended for local use)
Use this if you want predictable costs with higher usage quotas for the qwen3-coder-plus model.
When you choose the OpenAI-compatible option in the CLI, it will prompt you for:
> [!IMPORTANT]
>
> Coding Plan is only available for users in China mainland (Beijing region).
- **API key**
- **Base URL** (default: `https://api.openai.com/v1`)
- **Model** (default: `gpt-4o`)
- **How it works**: subscribe to the Coding Plan with a fixed monthly fee, then configure Qwen Code to use the dedicated endpoint and your subscription API key.
- **Requirements**: an active Coding Plan subscription from [Alibaba Cloud Bailian](https://bailian.console.aliyun.com/cn-beijing/?tab=globalset#/efm/coding_plan).
- **Benefits**: higher usage quotas, predictable monthly costs, access to latest qwen3-coder-plus model.
- **Cost & quota**: varies by plan (see table below).
#### Coding Plan Pricing & Quotas
| Feature | Lite Basic Plan | Pro Advanced Plan |
| :------------------ | :-------------------- | :-------------------- |
| **Price** | ¥40/month | ¥200/month |
| **5-Hour Limit** | Up to 1,200 requests | Up to 6,000 requests |
| **Weekly Limit** | Up to 9,000 requests | Up to 45,000 requests |
| **Monthly Limit** | Up to 18,000 requests | Up to 90,000 requests |
| **Supported Model** | qwen3-coder-plus | qwen3-coder-plus |
#### Quick Setup for Coding Plan
When you select the OpenAI-compatible option in the CLI, enter these values:
- **API key**: `sk-sp-xxxxx`
- **Base URL**: `https://coding.dashscope.aliyuncs.com/v1`
- **Model**: `qwen3-coder-plus`
> **Note**: Coding Plan API keys have the format `sk-sp-xxxxx`, which is different from standard Alibaba Cloud API keys.
#### Configure via Environment Variables
Set these environment variables to use Coding Plan:
```bash
export OPENAI_API_KEY="your-coding-plan-api-key" # Format: sk-sp-xxxxx
export OPENAI_BASE_URL="https://coding.dashscope.aliyuncs.com/v1"
export OPENAI_MODEL="qwen3-coder-plus"
```
For more details about Coding Plan, including subscription options and troubleshooting, see the [full Coding Plan documentation](https://bailian.console.aliyun.com/cn-beijing/?tab=doc#/doc/?type=model&url=3005961).
### Other OpenAI-compatible Providers
If you are using other providers (OpenAI, Azure, local LLMs, etc.), use the following configuration methods.
> **Note:** the CLI may display the key in plain text for verification. Make sure your terminal is not being recorded or shared.
### Configure via command-line arguments

View File

@@ -241,6 +241,7 @@ Per-field precedence for `generationConfig`:
| ------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `context.fileName` | string or array of strings | The name of the context file(s). | `undefined` |
| `context.importFormat` | string | The format to use when importing memory. | `undefined` |
| `context.discoveryMaxDirs` | number | Maximum number of directories to search for memory. | `200` |
| `context.includeDirectories` | array | Additional directories to include in the workspace context. Specifies an array of additional absolute or relative paths to include in the workspace context. Missing directories will be skipped with a warning by default. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag. | `[]` |
| `context.loadFromIncludeDirectories` | boolean | Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory. | `false` |
| `context.fileFiltering.respectGitIgnore` | boolean | Respect .gitignore files when searching. | `true` |
@@ -274,6 +275,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
| `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | |
| `tools.experimental.skills` | boolean | Enable experimental Agent Skills feature | `false` | |
#### mcp
@@ -310,12 +312,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
>
> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
#### experimental
| Setting | Type | Description | Default |
| --------------------- | ------- | -------------------------------- | ------- |
| `experimental.skills` | boolean | Enable experimental Agent Skills | `false` |
#### mcpServers
Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
@@ -534,13 +530,16 @@ Here's a conceptual example of what a context file at the root of a TypeScript p
This example demonstrates how you can provide general project context, specific coding conventions, and even notes about particular files or components. The more relevant and precise your context files are, the better the AI can assist you. Project-specific context files are highly encouraged to establish conventions and context.
- **Hierarchical Loading and Precedence:** The CLI implements a hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
- **Hierarchical Loading and Precedence:** The CLI implements a sophisticated hierarchical memory system by loading context files (e.g., `QWEN.md`) from several locations. Content from files lower in this list (more specific) typically overrides or supplements content from files higher up (more general). The exact concatenation order and final context can be inspected using the `/memory show` command. The typical loading order is:
1. **Global Context File:**
- Location: `~/.qwen/<configured-context-filename>` (e.g., `~/.qwen/QWEN.md` in your user home directory).
- Scope: Provides default instructions for all your projects.
2. **Project Root & Ancestors Context Files:**
- Location: The CLI searches for the configured context file in the current working directory and then in each parent directory up to either the project root (identified by a `.git` folder) or your home directory.
- Scope: Provides context relevant to the entire project or a significant portion of it.
3. **Sub-directory Context Files (Contextual/Local):**
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with the `context.discoveryMaxDirs` setting in your `settings.json` file.
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../configuration/memory).
- **Commands for Memory Management:**

View File

@@ -311,9 +311,9 @@ function setupAcpTest(
}
});
it('returns modes on initialize and allows setting mode and model', async () => {
it('returns modes on initialize and allows setting approval mode', async () => {
const rig = new TestRig();
rig.setup('acp mode and model');
rig.setup('acp approval mode');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
@@ -366,14 +366,8 @@ function setupAcpTest(
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as {
sessionId: string;
models: {
availableModels: Array<{ modelId: string }>;
};
};
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
expect(newSession.models.availableModels.length).toBeGreaterThan(0);
// Test 4: Set approval mode to 'yolo'
const setModeResult = (await sendRequest('session/set_mode', {
@@ -398,15 +392,6 @@ function setupAcpTest(
})) as { modeId: string };
expect(setModeResult3).toBeDefined();
expect(setModeResult3.modeId).toBe('default');
// Test 7: Set model using first available model
const firstModel = newSession.models.availableModels[0];
const setModelResult = (await sendRequest('session/set_model', {
sessionId: newSession.sessionId,
modelId: firstModel.modelId,
})) as { modelId: string };
expect(setModelResult).toBeDefined();
expect(setModelResult.modelId).toBeTruthy();
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.2",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.7.2",
"version": "0.7.1",
"workspaces": [
"packages/*"
],
@@ -39,6 +39,7 @@
"globals": "^16.0.0",
"husky": "^9.1.7",
"json": "^11.0.0",
"json-schema": "^0.4.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
"mnemonist": "^0.40.3",
@@ -10804,6 +10805,13 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true,
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -17310,7 +17318,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.7.2",
"version": "0.7.1",
"dependencies": {
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
@@ -17947,7 +17955,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.7.2",
"version": "0.7.1",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@@ -21408,7 +21416,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.7.2",
"version": "0.7.1",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -21420,7 +21428,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.7.2",
"version": "0.7.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.7.2",
"version": "0.7.1",
"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.7.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.7.1"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -94,6 +94,7 @@
"globals": "^16.0.0",
"husky": "^9.1.7",
"json": "^11.0.0",
"json-schema": "^0.4.0",
"lint-staged": "^16.1.6",
"memfs": "^4.42.0",
"mnemonist": "^0.40.3",

View File

@@ -0,0 +1,140 @@
# LSP 调试指南
本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。
## 1. 启用调试模式
CLI 支持调试模式,可以提供额外的日志信息:
```bash
# 使用 debug 标志运行
qwen --debug [你的命令]
# 或设置环境变量
DEBUG=true qwen [你的命令]
DEBUG_MODE=true qwen [你的命令]
```
## 2. LSP 配置选项
LSP 功能通过设置系统配置,包含以下选项:
- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`
- `lsp.allowed`: 允许的 LSP 服务器名称白名单
- `lsp.excluded`: 排除的 LSP 服务器名称黑名单
在 settings.json 中的示例配置:
```json
{
"lsp": {
"enabled": true,
"allowed": ["typescript-language-server", "pylsp"],
"excluded": ["gopls"]
}
}
```
也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。
## 3. NativeLspService 调试功能
`NativeLspService` 类包含几个调试功能:
### 3.1 控制台日志
服务向控制台输出状态消息:
- `LSP 服务器 ${name} 启动成功` - 服务器成功启动
- `LSP 服务器 ${name} 启动失败` - 服务器启动失败
- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现
### 3.2 错误处理
服务具有全面的错误处理和详细的错误消息
### 3.3 状态跟踪
您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态
## 4. 调试命令
```bash
# 启用调试运行
qwen --debug --prompt "调试 LSP 功能"
# 检查在您的项目中检测到哪些 LSP 服务器
# 系统会自动检测语言和相应的 LSP 服务器
```
## 5. 手动 LSP 服务器配置
您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器。
推荐使用新格式(以服务器名称为键),旧格式仍然兼容但会提示迁移:
```json
{
"languageServers": {
"pylsp": {
"command": "pylsp",
"args": [],
"languages": ["python"],
"transport": "stdio",
"settings": {},
"workspaceFolder": null,
"startupTimeout": 10000,
"shutdownTimeout": 3000,
"restartOnCrash": true,
"maxRestarts": 3,
"trustRequired": true
}
}
}
```
旧格式示例:
```json
{
"python": {
"command": "pylsp",
"args": [],
"transport": "stdio",
"trustRequired": true
}
}
```
## 6. LSP 问题排查
### 6.1 检查 LSP 服务器是否已安装
- 对于 TypeScript/JavaScript: `typescript-language-server`
- 对于 Python: `pylsp`
- 对于 Go: `gopls`
### 6.2 验证工作区信任
- LSP 服务器可能需要受信任的工作区才能启动
- 检查 `security.folderTrust.enabled` 设置
### 6.3 查看日志
- 查找以 `LSP 服务器` 开头的控制台消息
- 检查命令存在性和路径安全性问题
## 7. LSP 服务启动流程
LSP 服务的启动遵循以下流程:
1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言
2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄
3. **启动服务器**: `start()` 方法启动所有服务器句柄
4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换
## 8. 调试技巧
- 使用 `--debug` 标志查看详细的启动过程
- 检查工作区是否受信任(影响 LSP 服务器启动)
- 确认 LSP 服务器命令在系统 PATH 中可用
- 使用 `getStatus()` 方法监控服务器运行状态

View File

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

View File

@@ -8,7 +8,6 @@
import { z } from 'zod';
import * as schema from './schema.js';
import { ACP_ERROR_CODES } from './errorCodes.js';
export * from './schema.js';
import type { WritableStream, ReadableStream } from 'node:stream/web';
@@ -71,13 +70,6 @@ export class AgentSideConnection implements Client {
const validatedParams = schema.setModeRequestSchema.parse(params);
return agent.setMode(validatedParams);
}
case schema.AGENT_METHODS.session_set_model: {
if (!agent.setModel) {
throw RequestError.methodNotFound();
}
const validatedParams = schema.setModelRequestSchema.parse(params);
return agent.setModel(validatedParams);
}
default:
throw RequestError.methodNotFound(method);
}
@@ -350,51 +342,27 @@ export class RequestError extends Error {
}
static parseError(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.PARSE_ERROR,
'Parse error',
details,
);
return new RequestError(-32700, 'Parse error', details);
}
static invalidRequest(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.INVALID_REQUEST,
'Invalid request',
details,
);
return new RequestError(-32600, 'Invalid request', details);
}
static methodNotFound(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.METHOD_NOT_FOUND,
'Method not found',
details,
);
return new RequestError(-32601, 'Method not found', details);
}
static invalidParams(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.INVALID_PARAMS,
'Invalid params',
details,
);
return new RequestError(-32602, 'Invalid params', details);
}
static internalError(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.INTERNAL_ERROR,
'Internal error',
details,
);
return new RequestError(-32603, 'Internal error', details);
}
static authRequired(details?: string): RequestError {
return new RequestError(
ACP_ERROR_CODES.AUTH_REQUIRED,
'Authentication required',
details,
);
return new RequestError(-32000, 'Authentication required', details);
}
toResult<T>(): Result<T> {
@@ -440,5 +408,4 @@ export interface Agent {
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
cancel(params: schema.CancelNotification): Promise<void>;
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
}

View File

@@ -165,11 +165,30 @@ class GeminiAgent {
this.setupFileSystem(config);
const session = await this.createAndStoreSession(config);
const availableModels = this.buildAvailableModels(config);
const configuredModel = (
config.getModel() ||
this.config.getModel() ||
''
).trim();
const modelId = configuredModel || 'default';
const modelName = configuredModel || modelId;
return {
sessionId: session.getId(),
models: availableModels,
models: {
currentModelId: modelId,
availableModels: [
{
modelId,
name: modelName,
description: null,
_meta: {
contextLimit: tokenLimit(modelId),
},
},
],
_meta: null,
},
};
}
@@ -286,29 +305,15 @@ class GeminiAgent {
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw acp.RequestError.invalidParams(
`Session not found for id: ${params.sessionId}`,
);
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.setMode(params);
}
async setModel(params: acp.SetModelRequest): Promise<acp.SetModelResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw acp.RequestError.invalidParams(
`Session not found for id: ${params.sessionId}`,
);
}
return session.setModel(params);
}
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) {
throw acp.RequestError.authRequired(
'Use Qwen Code CLI to authenticate first.',
);
throw acp.RequestError.authRequired('No Selected Type');
}
try {
@@ -377,43 +382,4 @@ class GeminiAgent {
return session;
}
private buildAvailableModels(
config: Config,
): acp.NewSessionResponse['models'] {
const currentModelId = (
config.getModel() ||
this.config.getModel() ||
''
).trim();
const availableModels = config.getAvailableModels();
const mappedAvailableModels = availableModels.map((model) => ({
modelId: model.id,
name: model.label,
description: model.description ?? null,
_meta: {
contextLimit: tokenLimit(model.id),
},
}));
if (
currentModelId &&
!mappedAvailableModels.some((model) => model.modelId === currentModelId)
) {
mappedAvailableModels.unshift({
modelId: currentModelId,
name: currentModelId,
description: null,
_meta: {
contextLimit: tokenLimit(currentModelId),
},
});
}
return {
currentModelId,
availableModels: mappedAvailableModels,
};
}
}

View File

@@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const ACP_ERROR_CODES = {
// Parse error: invalid JSON received by server.
PARSE_ERROR: -32700,
// Invalid request: JSON is not a valid Request object.
INVALID_REQUEST: -32600,
// Method not found: method does not exist or is unavailable.
METHOD_NOT_FOUND: -32601,
// Invalid params: invalid method parameter(s).
INVALID_PARAMS: -32602,
// Internal error: implementation-defined server error.
INTERNAL_ERROR: -32603,
// Authentication required: must authenticate before operation.
AUTH_REQUIRED: -32000,
// Resource not found: e.g. missing file.
RESOURCE_NOT_FOUND: -32002,
} as const;
export type AcpErrorCode =
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];

View File

@@ -15,7 +15,6 @@ export const AGENT_METHODS = {
session_prompt: 'session/prompt',
session_list: 'session/list',
session_set_mode: 'session/set_mode',
session_set_model: 'session/set_model',
};
export const CLIENT_METHODS = {
@@ -267,18 +266,6 @@ export const modelInfoSchema = z.object({
name: z.string(),
});
export const setModelRequestSchema = z.object({
sessionId: z.string(),
modelId: z.string(),
});
export const setModelResponseSchema = z.object({
modelId: z.string(),
});
export type SetModelRequest = z.infer<typeof setModelRequestSchema>;
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
export const sessionModelStateSchema = z.object({
_meta: acpMetaSchema,
availableModels: z.array(modelInfoSchema),
@@ -605,7 +592,6 @@ export const agentResponseSchema = z.union([
promptResponseSchema,
listSessionsResponseSchema,
setModeResponseSchema,
setModelResponseSchema,
]);
export const requestPermissionRequestSchema = z.object({
@@ -638,7 +624,6 @@ export const agentRequestSchema = z.union([
promptRequestSchema,
listSessionsRequestSchema,
setModeRequestSchema,
setModelRequestSchema,
]);
export const agentNotificationSchema = sessionNotificationSchema;

View File

@@ -7,7 +7,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import { AcpFileSystemService } from './filesystem.js';
import { ACP_ERROR_CODES } from '../errorCodes.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
@@ -17,13 +16,11 @@ const createFallback = (): FileSystemService => ({
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
message: 'File not found',
};
it('parses path from ACP ENOENT message (quoted)', async () => {
const client = {
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -33,20 +30,15 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
errno: -2,
path: '/some/file.txt',
path: '/remote/file.txt',
});
});
it('re-throws other errors unchanged', async () => {
const otherError = {
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
};
it('falls back to requested path when none provided', async () => {
const client = {
readTextFile: vi.fn().mockRejectedValue(otherError),
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
@@ -56,34 +48,12 @@ describe('AcpFileSystemService', () => {
createFallback(),
);
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
code: ACP_ERROR_CODES.INTERNAL_ERROR,
message: 'Internal error',
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
});
});
it('uses fallback when readTextFile capability is disabled', async () => {
const client = {
readTextFile: vi.fn(),
} as unknown as import('../acp.js').Client;
const fallback = createFallback();
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
'fallback content',
);
const svc = new AcpFileSystemService(
client,
'session-3',
{ readTextFile: false, writeTextFile: true },
fallback,
);
const result = await svc.readTextFile('/some/file.txt');
expect(result).toBe('fallback content');
expect(fallback.readTextFile).toHaveBeenCalledWith('/some/file.txt');
expect(client.readTextFile).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,7 +6,6 @@
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import type * as acp from '../acp.js';
import { ACP_ERROR_CODES } from '../errorCodes.js';
/**
* ACP client-based implementation of FileSystemService
@@ -24,31 +23,25 @@ export class AcpFileSystemService implements FileSystemService {
return this.fallback.readTextFile(filePath);
}
let response: { content: string };
try {
response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
} catch (error) {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
const response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: null,
});
if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) {
const err = new Error(
`File not found: ${filePath}`,
) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
err.path = filePath;
throw err;
}
throw error;
if (response.content.startsWith('ERROR: ENOENT:')) {
// Treat ACP error strings as structured ENOENT errors without
// assuming a specific platform format.
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err;
}
return response.content;

View File

@@ -1,174 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Session } from './Session.js';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import type * as acp from '../acp.js';
import type { LoadedSettings } from '../../config/settings.js';
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
vi.mock('../../nonInteractiveCliCommands.js', () => ({
getAvailableCommands: vi.fn(),
handleSlashCommand: vi.fn(),
}));
describe('Session', () => {
let mockChat: GeminiChat;
let mockConfig: Config;
let mockClient: acp.Client;
let mockSettings: LoadedSettings;
let session: Session;
let currentModel: string;
let setModelSpy: ReturnType<typeof vi.fn>;
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
currentModel = 'qwen3-code-plus';
setModelSpy = vi.fn().mockImplementation(async (modelId: string) => {
currentModel = modelId;
});
mockChat = {
sendMessageStream: vi.fn(),
addHistory: vi.fn(),
} as unknown as GeminiChat;
mockConfig = {
setApprovalMode: vi.fn(),
setModel: setModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
} as unknown as Config;
mockClient = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
requestPermission: vi.fn().mockResolvedValue({
outcome: { outcome: 'selected', optionId: 'proceed_once' },
}),
sendCustomNotification: vi.fn().mockResolvedValue(undefined),
} as unknown as acp.Client;
mockSettings = {
merged: {},
} as LoadedSettings;
getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands)
.getAvailableCommands as unknown as ReturnType<typeof vi.fn>;
getAvailableCommandsSpy.mockResolvedValue([]);
session = new Session(
'test-session-id',
mockChat,
mockConfig,
mockClient,
mockSettings,
);
});
describe('setMode', () => {
it.each([
['plan', ApprovalMode.PLAN],
['default', ApprovalMode.DEFAULT],
['auto-edit', ApprovalMode.AUTO_EDIT],
['yolo', ApprovalMode.YOLO],
] as const)('maps %s mode', async (modeId, expected) => {
const result = await session.setMode({
sessionId: 'test-session-id',
modeId,
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
expect(result).toEqual({ modeId });
});
});
describe('setModel', () => {
it('sets model via config and returns current model', async () => {
const result = await session.setModel({
sessionId: 'test-session-id',
modelId: ' qwen3-coder-plus ',
});
expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', {
reason: 'user_request_acp',
context: 'session/set_model',
});
expect(mockConfig.getModel).toHaveBeenCalled();
expect(result).toEqual({ modelId: 'qwen3-coder-plus' });
});
it('rejects empty/whitespace model IDs', async () => {
await expect(
session.setModel({
sessionId: 'test-session-id',
modelId: ' ',
}),
).rejects.toThrow('Invalid params');
expect(mockConfig.setModel).not.toHaveBeenCalled();
});
it('propagates errors from config.setModel', async () => {
const configError = new Error('Invalid model');
setModelSpy.mockRejectedValueOnce(configError);
await expect(
session.setModel({
sessionId: 'test-session-id',
modelId: 'invalid-model',
}),
).rejects.toThrow('Invalid model');
});
});
describe('sendAvailableCommandsUpdate', () => {
it('sends available_commands_update from getAvailableCommands()', async () => {
getAvailableCommandsSpy.mockResolvedValueOnce([
{
name: 'init',
description: 'Initialize project context',
},
]);
await session.sendAvailableCommandsUpdate();
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
mockConfig,
expect.any(AbortSignal),
);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{
name: 'init',
description: 'Initialize project context',
input: null,
},
],
},
});
});
it('swallows errors and does not throw', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
getAvailableCommandsSpy.mockRejectedValueOnce(
new Error('Command discovery failed'),
);
await expect(
session.sendAvailableCommandsUpdate(),
).resolves.toBeUndefined();
expect(mockClient.sessionUpdate).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
});

View File

@@ -52,8 +52,6 @@ import type {
AvailableCommandsUpdate,
SetModeRequest,
SetModeResponse,
SetModelRequest,
SetModelResponse,
ApprovalModeValue,
CurrentModeUpdate,
} from '../schema.js';
@@ -350,31 +348,6 @@ export class Session implements SessionContext {
return { modeId: params.modeId };
}
/**
* Sets the model for the current session.
* Validates the model ID and switches the model via Config.
*/
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
const modelId = params.modelId.trim();
if (!modelId) {
throw acp.RequestError.invalidParams('modelId cannot be empty');
}
// Attempt to set the model using config
await this.config.setModel(modelId, {
reason: 'user_request_acp',
context: 'session/set_model',
});
// Get updated model info
const currentModel = this.config.getModel();
return {
modelId: currentModel,
};
}
/**
* Sends a current_mode_update notification to the client.
* Called after the agent switches modes (e.g., from exit_plan_mode tool).

View File

@@ -20,6 +20,25 @@ import { ExtensionStorage, type Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { NativeLspService } from '../services/lsp/NativeLspService.js';
const createNativeLspServiceInstance = () => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
});
vi.mock('../services/lsp/NativeLspService.js', () => ({
NativeLspService: vi.fn().mockImplementation(() => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
})),
}));
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
@@ -27,6 +46,17 @@ vi.mock('./trustedFolders.js', () => ({
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
}));
const nativeLspServiceMock = vi.mocked(NativeLspService);
const getLastLspInstance = () => {
const results = nativeLspServiceMock.mock.results;
if (results.length === 0) {
return undefined;
}
return results[results.length - 1]?.value as ReturnType<
typeof createNativeLspServiceInstance
>;
};
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof import('fs')>();
const pathMod = await import('node:path');
@@ -516,6 +546,10 @@ describe('loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
nativeLspServiceMock.mockReset();
nativeLspServiceMock.mockImplementation(() =>
createNativeLspServiceInstance(),
);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
@@ -585,6 +619,63 @@ describe('loadCliConfig', () => {
expect(config.getShowMemoryUsage()).toBe(false);
});
it('should initialize native LSP service when enabled', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
lsp: {
enabled: true,
allowed: ['typescript-language-server'],
excluded: ['pylsp'],
},
};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
expect(config.isLspEnabled()).toBe(true);
expect(config.getLspAllowed()).toEqual(['typescript-language-server']);
expect(config.getLspExcluded()).toEqual(['pylsp']);
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
const lspInstance = getLastLspInstance();
expect(lspInstance).toBeDefined();
expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1);
expect(lspInstance?.start).toHaveBeenCalledTimes(1);
const options = nativeLspServiceMock.mock.calls[0][5];
expect(options?.allowedServers).toEqual(['typescript-language-server']);
expect(options?.excludedServers).toEqual(['pylsp']);
});
it('should skip native LSP startup when startLsp option is false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { lsp: { enabled: true } };
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
undefined,
{ startLsp: false },
);
expect(config.isLspEnabled()).toBe(true);
expect(nativeLspServiceMock).not.toHaveBeenCalled();
expect(getLastLspInstance()).toBeUndefined();
});
it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
@@ -1196,6 +1287,11 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
],
true,
'tree',
{
respectGitIgnore: false,
respectQwenIgnore: true,
},
undefined, // maxDirs
);
});

View File

@@ -9,6 +9,7 @@ import {
AuthType,
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileDiscoveryService,
getCurrentGeminiMdFilename,
loadServerHierarchicalMemory,
@@ -20,8 +21,11 @@ import {
OutputFormat,
isToolEnabled,
SessionService,
ideContextStore,
type ResumedSessionData,
type FileFilteringOptions,
type MCPServerConfig,
type LspClient,
type ToolName,
EditTool,
ShellTool,
@@ -46,6 +50,7 @@ import { annotateActiveExtensions } from './extension.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
import { NativeLspService } from '../services/lsp/NativeLspService.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
@@ -152,6 +157,105 @@ export interface CliArgs {
channel: string | undefined;
}
export interface LoadCliConfigOptions {
/**
* Whether to start the native LSP service during config load.
* Disable when doing preflight runs (e.g., sandbox preparation).
*/
startLsp?: boolean;
}
class NativeLspClient implements LspClient {
constructor(private readonly service: NativeLspService) {}
workspaceSymbols(query: string, limit?: number) {
return this.service.workspaceSymbols(query, limit);
}
definitions(
location: Parameters<NativeLspService['definitions']>[0],
serverName?: string,
limit?: number,
) {
return this.service.definitions(location, serverName, limit);
}
references(
location: Parameters<NativeLspService['references']>[0],
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
) {
return this.service.references(
location,
serverName,
includeDeclaration,
limit,
);
}
/**
* Get hover information (documentation, type info) for a symbol.
*/
hover(
location: Parameters<NativeLspService['hover']>[0],
serverName?: string,
) {
return this.service.hover(location, serverName);
}
/**
* Get all symbols in a document.
*/
documentSymbols(uri: string, serverName?: string, limit?: number) {
return this.service.documentSymbols(uri, serverName, limit);
}
/**
* Find implementations of an interface or abstract method.
*/
implementations(
location: Parameters<NativeLspService['implementations']>[0],
serverName?: string,
limit?: number,
) {
return this.service.implementations(location, serverName, limit);
}
/**
* Prepare call hierarchy item at a position (functions/methods).
*/
prepareCallHierarchy(
location: Parameters<NativeLspService['prepareCallHierarchy']>[0],
serverName?: string,
limit?: number,
) {
return this.service.prepareCallHierarchy(location, serverName, limit);
}
/**
* Find all functions/methods that call the given function.
*/
incomingCalls(
item: Parameters<NativeLspService['incomingCalls']>[0],
serverName?: string,
limit?: number,
) {
return this.service.incomingCalls(item, serverName, limit);
}
/**
* Find all functions/methods called by the given function.
*/
outgoingCalls(
item: Parameters<NativeLspService['outgoingCalls']>[0],
serverName?: string,
limit?: number,
) {
return this.service.outgoingCalls(item, serverName, limit);
}
}
function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
@@ -332,14 +436,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
.option('experimental-skills', {
type: 'boolean',
description: 'Enable experimental Skills feature',
default: (() => {
const legacySkills = (
settings as Settings & {
tools?: { experimental?: { skills?: boolean } };
}
).tools?.experimental?.skills;
return settings.experimental?.skills ?? legacySkills ?? false;
})(),
default: settings.tools?.experimental?.skills ?? false,
})
.option('channel', {
type: 'string',
@@ -648,6 +745,7 @@ export async function loadHierarchicalGeminiMemory(
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number }> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
@@ -673,6 +771,8 @@ export async function loadHierarchicalGeminiMemory(
extensionContextFilePaths,
folderTrust,
memoryImportFormat,
fileFilteringOptions,
settings.context?.discoveryMaxDirs,
);
}
@@ -691,6 +791,7 @@ export async function loadCliConfig(
extensionEnablementManager: ExtensionEnablementManager,
argv: CliArgs,
cwd: string = process.cwd(),
options: LoadCliConfigOptions = {},
): Promise<Config> {
const debugMode = isDebugMode(argv);
@@ -742,6 +843,11 @@ export async function loadCliConfig(
const fileService = new FileDiscoveryService(cwd);
const fileFiltering = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...settings.context?.fileFiltering,
};
const includeDirectories = (settings.context?.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
@@ -758,9 +864,17 @@ export async function loadCliConfig(
extensionContextFilePaths,
trustedFolder,
memoryImportFormat,
fileFiltering,
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
// LSP configuration derived from settings; defaults to disabled for safety.
const lspEnabled = settings.lsp?.enabled ?? false;
const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed;
const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded;
const lspLanguageServers = settings.lsp?.languageServers;
let lspClient: LspClient | undefined;
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
@@ -987,7 +1101,7 @@ export async function loadCliConfig(
const modelProvidersConfig = settings.modelProviders;
return new Config({
const config = new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
@@ -1077,7 +1191,40 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
lsp: {
enabled: lspEnabled,
allowed: lspAllowed,
excluded: lspExcluded,
},
});
const shouldStartLsp = options.startLsp ?? true;
if (shouldStartLsp && lspEnabled) {
try {
const lspService = new NativeLspService(
config,
config.getWorkspaceContext(),
appEvents,
fileService,
ideContextStore,
{
allowedServers: lspAllowed,
excludedServers: lspExcluded,
requireTrustedWorkspace: folderTrust,
inlineServerConfigs: lspLanguageServers,
},
);
await lspService.discoverAndPrepare();
await lspService.start();
lspClient = new NativeLspClient(lspService);
config.setLspClient(lspClient);
} catch (err) {
logger.warn('Failed to initialize native LSP service:', err);
}
}
return config;
}
function allowedMcpServers(

View File

@@ -122,10 +122,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Auto-completion
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation uses only arrow keys
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
[Command.COMPLETION_UP]: [{ key: 'up' }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
// Text input
// Must also exclude shift to allow shift+enter for newline

View File

@@ -0,0 +1,38 @@
import type { JSONSchema7 } from 'json-schema';
export const lspSettingsSchema: JSONSchema7 = {
type: 'object',
properties: {
'lsp.enabled': {
type: 'boolean',
default: true,
description: '启用 LSP 语言服务器协议支持'
},
'lsp.allowed': {
type: 'array',
items: {
type: 'string'
},
default: [],
description: '允许运行的 LSP 服务器列表'
},
'lsp.excluded': {
type: 'array',
items: {
type: 'string'
},
default: [],
description: '禁止运行的 LSP 服务器列表'
},
'lsp.autoDetect': {
type: 'boolean',
default: true,
description: '自动检测项目语言并启动相应 LSP 服务器'
},
'lsp.serverTimeout': {
type: 'number',
default: 10000,
description: 'LSP 服务器启动超时时间(毫秒)'
}
}
};

View File

@@ -106,6 +106,7 @@ const MIGRATION_MAP: Record<string, string> = {
mcpServers: 'mcpServers',
mcpServerCommand: 'mcp.serverCommand',
memoryImportFormat: 'context.importFormat',
memoryDiscoveryMaxDirs: 'context.discoveryMaxDirs',
model: 'model.name',
preferredEditor: 'general.preferredEditor',
sandbox: 'tools.sandbox',
@@ -159,6 +160,39 @@ export function getSystemDefaultsPath(): string {
);
}
function getVsCodeSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, '.vscode', 'settings.json');
}
function loadVsCodeSettings(workspaceDir: string): Settings {
const vscodeSettingsPath = getVsCodeSettingsPath(workspaceDir);
try {
if (fs.existsSync(vscodeSettingsPath)) {
const content = fs.readFileSync(vscodeSettingsPath, 'utf-8');
const rawSettings: unknown = JSON.parse(stripJsonComments(content));
if (
typeof rawSettings !== 'object' ||
rawSettings === null ||
Array.isArray(rawSettings)
) {
console.error(
`VS Code settings file is not a valid JSON object: ${vscodeSettingsPath}`,
);
return {};
}
return rawSettings as Settings;
}
} catch (error: unknown) {
console.error(
`Error loading VS Code settings from ${vscodeSettingsPath}:`,
getErrorMessage(error),
);
}
return {};
}
export type { DnsResolutionOrder } from './settingsSchema.js';
export enum SettingScope {
@@ -722,6 +756,9 @@ export function loadSettings(
workspaceDir,
).getWorkspaceSettingsPath();
// Load VS Code settings as an additional source of configuration
const vscodeSettings = loadVsCodeSettings(workspaceDir);
const loadAndMigrate = (
filePath: string,
scope: SettingScope,
@@ -826,6 +863,14 @@ export function loadSettings(
userSettings = resolveEnvVarsInObject(userResult.settings);
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
// Merge VS Code settings into workspace settings (VS Code settings take precedence)
workspaceSettings = customDeepMerge(
getMergeStrategyForPath,
{},
workspaceSettings,
vscodeSettings,
) as Settings;
// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
userSettings.ui.theme = DefaultLight.name;
@@ -839,11 +884,13 @@ export function loadSettings(
}
// For the initial trust check, we can only use user and system settings.
// We also include VS Code settings as they may contain trust-related settings
const initialTrustCheckSettings = customDeepMerge(
getMergeStrategyForPath,
{},
systemSettings,
userSettings,
vscodeSettings, // Include VS Code settings
);
const isTrusted =
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
@@ -857,9 +904,18 @@ export function loadSettings(
isTrusted,
);
// Add VS Code settings to the temp merged settings for environment loading
// Since loadEnvironment depends on settings, we need to consider VS Code settings as well
const tempMergedSettingsWithVsCode = customDeepMerge(
getMergeStrategyForPath,
{},
tempMergedSettings,
vscodeSettings,
) as Settings;
// loadEnviroment depends on settings so we have to create a temp version of
// the settings to avoid a cycle
loadEnvironment(tempMergedSettings);
loadEnvironment(tempMergedSettingsWithVsCode);
// Create LoadedSettings first
@@ -921,21 +977,6 @@ export function migrateDeprecatedSettings(
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
}
const legacySkills = (
settings as Settings & {
tools?: { experimental?: { skills?: boolean } };
}
).tools?.experimental?.skills;
if (
legacySkills !== undefined &&
settings.experimental?.skills === undefined
) {
console.log(
`Migrating deprecated tools.experimental.skills setting from ${scope} settings...`,
);
loadedSettings.setValue(scope, 'experimental.skills', legacySkills);
}
};
processScope(SettingScope.User);

View File

@@ -434,16 +434,6 @@ const SETTINGS_SCHEMA = {
'Show welcome back dialog when returning to a project with conversation history.',
showInDialog: true,
},
enableUserFeedback: {
type: 'boolean',
label: 'Enable User Feedback',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Show optional feedback dialog after conversations to help improve Qwen performance.',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',
@@ -474,15 +464,6 @@ const SETTINGS_SCHEMA = {
},
},
},
feedbackLastShownTimestamp: {
type: 'number',
label: 'Feedback Last Shown Timestamp',
category: 'UI',
requiresRestart: false,
default: 0,
description: 'The last time the feedback dialog was shown.',
showInDialog: false,
},
},
},
@@ -741,6 +722,15 @@ const SETTINGS_SCHEMA = {
description: 'The format to use when importing memory.',
showInDialog: false,
},
discoveryMaxDirs: {
type: 'number',
label: 'Memory Discovery Max Dirs',
category: 'Context',
requiresRestart: false,
default: 200,
description: 'Maximum number of directories to search for memory.',
showInDialog: true,
},
includeDirectories: {
type: 'array',
label: 'Include Directories',
@@ -991,6 +981,27 @@ const SETTINGS_SCHEMA = {
description: 'The number of lines to keep when truncating tool output.',
showInDialog: true,
},
experimental: {
type: 'object',
label: 'Experimental',
category: 'Tools',
requiresRestart: true,
default: {},
description: 'Experimental tool features.',
showInDialog: false,
properties: {
skills: {
type: 'boolean',
label: 'Skills',
category: 'Tools',
requiresRestart: true,
default: false,
description:
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
showInDialog: true,
},
},
},
},
},
@@ -1032,6 +1043,59 @@ const SETTINGS_SCHEMA = {
},
},
},
lsp: {
type: 'object',
label: 'LSP',
category: 'LSP',
requiresRestart: true,
default: {},
description:
'Settings for the native Language Server Protocol integration.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable LSP',
category: 'LSP',
requiresRestart: true,
default: false,
description:
'Enable the native LSP client to connect to language servers discovered in the workspace.',
showInDialog: false,
},
allowed: {
type: 'array',
label: 'Allow LSP Servers',
category: 'LSP',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Optional allowlist of LSP server names. If set, only matching servers will start.',
showInDialog: false,
},
excluded: {
type: 'array',
label: 'Exclude LSP Servers',
category: 'LSP',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Optional blocklist of LSP server names that should not start.',
showInDialog: false,
},
languageServers: {
type: 'object',
label: 'LSP Language Servers',
category: 'LSP',
requiresRestart: true,
default: {} as Record<string, unknown>,
description:
'Inline LSP server configuration (same format as .lsp.json).',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
},
},
},
useSmartEdit: {
type: 'boolean',
label: 'Use Smart Edit',
@@ -1217,16 +1281,6 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
skills: {
type: 'boolean',
label: 'Skills',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',

View File

@@ -254,6 +254,8 @@ export async function main() {
[],
new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()),
argv,
undefined,
{ startLsp: false },
);
if (!settings.merged.security?.auth?.useExternal) {

View File

@@ -289,13 +289,6 @@ export default {
'Show Citations': 'Quellenangaben anzeigen',
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
'Enable User Feedback': 'Benutzerfeedback aktivieren',
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Good: 'Gut',
'Not Sure Yet': 'Noch nicht sicher',
'Any other key': 'Beliebige andere Taste',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
'Screen Reader Mode': 'Bildschirmleser-Modus',
'IDE Mode': 'IDE-Modus',

View File

@@ -286,13 +286,6 @@ export default {
'Show Citations': 'Show Citations',
'Custom Witty Phrases': 'Custom Witty Phrases',
'Enable Welcome Back': 'Enable Welcome Back',
'Enable User Feedback': 'Enable User Feedback',
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Good: 'Good',
'Not Sure Yet': 'Not Sure Yet',
'Any other key': 'Any other key',
'Disable Loading Phrases': 'Disable Loading Phrases',
'Screen Reader Mode': 'Screen Reader Mode',
'IDE Mode': 'IDE Mode',

View File

@@ -289,13 +289,6 @@ export default {
'Show Citations': 'Показывать цитаты',
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
'Enable Welcome Back': 'Включить приветствие при возврате',
'Enable User Feedback': 'Включить отзывы пользователей',
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Good: 'Хорошо',
'Not Sure Yet': 'Пока не уверен',
'Any other key': 'Любая другая клавиша',
'Disable Loading Phrases': 'Отключить фразы при загрузке',
'Screen Reader Mode': 'Режим программы чтения с экрана',
'IDE Mode': 'Режим IDE',

View File

@@ -277,12 +277,6 @@ export default {
'Show Citations': '显示引用',
'Custom Witty Phrases': '自定义诙谐短语',
'Enable Welcome Back': '启用欢迎回来',
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Good: '满意',
'Not Sure Yet': '暂不评价',
'Any other key': '任意其他键',
'Disable Loading Phrases': '禁用加载短语',
'Screen Reader Mode': '屏幕阅读器模式',
'IDE Mode': 'IDE 模式',

View File

@@ -0,0 +1,391 @@
import * as cp from 'node:child_process';
import * as net from 'node:net';
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
timer: NodeJS.Timeout;
}
class JsonRpcConnection {
private buffer = '';
private nextId = 1;
private disposed = false;
private pendingRequests = new Map<string | number, PendingRequest>();
private notificationHandlers: Array<(notification: JsonRpcMessage) => void> =
[];
private requestHandlers: Array<
(request: JsonRpcMessage) => Promise<unknown>
> = [];
constructor(
private readonly writer: (data: string) => void,
private readonly disposer?: () => void,
) {}
listen(readable: NodeJS.ReadableStream): void {
readable.on('data', (chunk: Buffer) => this.handleData(chunk));
readable.on('error', (error) =>
this.disposePending(
error instanceof Error ? error : new Error(String(error)),
),
);
}
send(message: JsonRpcMessage): void {
this.writeMessage(message);
}
onNotification(handler: (notification: JsonRpcMessage) => void): void {
this.notificationHandlers.push(handler);
}
onRequest(handler: (request: JsonRpcMessage) => Promise<unknown>): void {
this.requestHandlers.push(handler);
}
async initialize(params: unknown): Promise<unknown> {
return this.sendRequest('initialize', params);
}
async shutdown(): Promise<void> {
try {
await this.sendRequest('shutdown', {});
} catch (_error) {
// Ignore shutdown errors the server may already be gone.
} finally {
this.end();
}
}
request(method: string, params: unknown): Promise<unknown> {
return this.sendRequest(method, params);
}
end(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.disposePending();
this.disposer?.();
}
private sendRequest(method: string, params: unknown): Promise<unknown> {
if (this.disposed) {
return Promise.resolve(undefined);
}
const id = this.nextId++;
const payload: JsonRpcMessage = {
jsonrpc: '2.0',
id,
method,
params,
};
const requestPromise = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`LSP request timeout: ${method}`));
}, 15000);
this.pendingRequests.set(id, { resolve, reject, timer });
});
this.writeMessage(payload);
return requestPromise;
}
private async handleServerRequest(message: JsonRpcMessage): Promise<void> {
const handler = this.requestHandlers[this.requestHandlers.length - 1];
if (!handler) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: `Method not supported: ${message.method}`,
},
});
return;
}
try {
const result = await handler(message);
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
result: result ?? null,
});
} catch (error) {
this.writeMessage({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: (error as Error).message ?? 'Internal error',
},
});
}
}
private handleData(chunk: Buffer): void {
if (this.disposed) {
return;
}
this.buffer += chunk.toString('utf8');
while (true) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
break;
}
const header = this.buffer.slice(0, headerEnd);
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
if (!lengthMatch) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const contentLength = Number(lengthMatch[1]);
const messageStart = headerEnd + 4;
const messageEnd = messageStart + contentLength;
if (this.buffer.length < messageEnd) {
break;
}
const body = this.buffer.slice(messageStart, messageEnd);
this.buffer = this.buffer.slice(messageEnd);
try {
const message = JSON.parse(body);
this.routeMessage(message);
} catch {
// ignore malformed messages
}
}
}
private routeMessage(message: JsonRpcMessage): void {
if (typeof message?.id !== 'undefined' && !message.method) {
const pending = this.pendingRequests.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(
new Error(message.error.message || 'LSP request failed'),
);
} else {
pending.resolve(message.result);
}
return;
}
if (message?.method && typeof message.id !== 'undefined') {
void this.handleServerRequest(message);
return;
}
if (message?.method) {
for (const handler of this.notificationHandlers) {
try {
handler(message);
} catch {
// ignore handler errors
}
}
}
}
private writeMessage(message: JsonRpcMessage): void {
if (this.disposed) {
return;
}
const json = JSON.stringify(message);
const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
this.writer(header + json);
}
private disposePending(error?: Error): void {
for (const [, pending] of Array.from(this.pendingRequests)) {
clearTimeout(pending.timer);
pending.reject(error ?? new Error('LSP connection closed'));
}
this.pendingRequests.clear();
}
}
interface LspConnection {
connection: JsonRpcConnection;
process?: cp.ChildProcess;
socket?: net.Socket;
}
interface SocketConnectionOptions {
host?: string;
port?: number;
path?: string;
}
interface JsonRpcMessage {
jsonrpc: string;
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
export class LspConnectionFactory {
/**
* 创建基于 stdio 的 LSP 连接
*/
static async createStdioConnection(
command: string,
args: string[],
options?: cp.SpawnOptions,
timeoutMs = 10000,
): Promise<LspConnection> {
return new Promise((resolve, reject) => {
const spawnOptions: cp.SpawnOptions = {
stdio: 'pipe',
...options,
};
const processInstance = cp.spawn(command, args, spawnOptions);
const timeoutId = setTimeout(() => {
reject(new Error('LSP server spawn timeout'));
if (!processInstance.killed) {
processInstance.kill();
}
}, timeoutMs);
processInstance.once('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to spawn LSP server: ${error.message}`));
});
processInstance.once('spawn', () => {
clearTimeout(timeoutId);
if (!processInstance.stdout || !processInstance.stdin) {
reject(new Error('LSP server stdio not available'));
return;
}
const connection = new JsonRpcConnection(
(payload) => processInstance.stdin?.write(payload),
() => processInstance.stdin?.end(),
);
connection.listen(processInstance.stdout);
processInstance.once('exit', () => connection.end());
processInstance.once('close', () => connection.end());
resolve({
connection,
process: processInstance,
});
});
});
}
/**
* 创建基于 TCP 的 LSP 连接
*/
static async createTcpConnection(
host: string,
port: number,
timeoutMs = 10000,
): Promise<LspConnection> {
return LspConnectionFactory.createSocketConnection(
{ host, port },
timeoutMs,
);
}
/**
* 创建基于 socket 的 LSP 连接(支持 TCP 或 unix socket
*/
static async createSocketConnection(
options: SocketConnectionOptions,
timeoutMs = 10000,
): Promise<LspConnection> {
return new Promise((resolve, reject) => {
const socketOptions = options.path
? { path: options.path }
: { host: options.host ?? '127.0.0.1', port: options.port };
if (!('path' in socketOptions) && !socketOptions.port) {
reject(new Error('Socket transport requires port or path'));
return;
}
const socket = net.createConnection(socketOptions);
const timeoutId = setTimeout(() => {
reject(new Error('LSP server connection timeout'));
socket.destroy();
}, timeoutMs);
const onError = (error: Error) => {
clearTimeout(timeoutId);
reject(new Error(`Failed to connect to LSP server: ${error.message}`));
};
socket.once('error', onError);
socket.on('connect', () => {
clearTimeout(timeoutId);
socket.off('error', onError);
const connection = new JsonRpcConnection(
(payload) => socket.write(payload),
() => socket.destroy(),
);
connection.listen(socket);
socket.once('close', () => connection.end());
socket.once('error', () => connection.end());
resolve({
connection,
socket,
});
});
});
}
/**
* 关闭 LSP 连接
*/
static async closeConnection(lspConnection: LspConnection): Promise<void> {
if (lspConnection.connection) {
try {
await lspConnection.connection.shutdown();
} catch (e) {
console.warn('LSP shutdown failed:', e);
} finally {
lspConnection.connection.end();
}
}
if (lspConnection.process && !lspConnection.process.killed) {
lspConnection.process.kill();
}
if (lspConnection.socket && !lspConnection.socket.destroyed) {
lspConnection.socket.destroy();
}
}
}

View File

@@ -0,0 +1,127 @@
import { NativeLspService } from './NativeLspService.js';
import { EventEmitter } from 'events';
import type {
Config as CoreConfig,
WorkspaceContext,
FileDiscoveryService,
IdeContextStore,
} from '@qwen-code/qwen-code-core';
// 模拟依赖项
class MockConfig {
rootPath = '/test/workspace';
isTrustedFolder(): boolean {
return true;
}
get(_key: string) {
return undefined;
}
getProjectRoot(): string {
return this.rootPath;
}
}
class MockWorkspaceContext {
rootPath = '/test/workspace';
async fileExists(_path: string): Promise<boolean> {
return _path.endsWith('.json') || _path.includes('package.json');
}
async readFile(_path: string): Promise<string> {
if (_path.includes('.lsp.json')) {
return JSON.stringify({
typescript: {
command: 'typescript-language-server',
args: ['--stdio'],
transport: 'stdio',
},
});
}
return '{}';
}
resolvePath(_path: string): string {
return this.rootPath + '/' + _path;
}
isPathWithinWorkspace(_path: string): boolean {
return true;
}
getDirectories(): string[] {
return [this.rootPath];
}
}
class MockFileDiscoveryService {
async discoverFiles(_root: string, _options: unknown): Promise<string[]> {
// 模拟发现一些文件
return [
'/test/workspace/src/index.ts',
'/test/workspace/src/utils.ts',
'/test/workspace/server.py',
'/test/workspace/main.go',
];
}
shouldIgnoreFile(): boolean {
return false;
}
}
class MockIdeContextStore {
// 模拟 IDE 上下文存储
}
describe('NativeLspService', () => {
let lspService: NativeLspService;
let mockConfig: MockConfig;
let mockWorkspace: MockWorkspaceContext;
let mockFileDiscovery: MockFileDiscoveryService;
let mockIdeStore: MockIdeContextStore;
let eventEmitter: EventEmitter;
beforeEach(() => {
mockConfig = new MockConfig();
mockWorkspace = new MockWorkspaceContext();
mockFileDiscovery = new MockFileDiscoveryService();
mockIdeStore = new MockIdeContextStore();
eventEmitter = new EventEmitter();
lspService = new NativeLspService(
mockConfig as unknown as CoreConfig,
mockWorkspace as unknown as WorkspaceContext,
eventEmitter,
mockFileDiscovery as unknown as FileDiscoveryService,
mockIdeStore as unknown as IdeContextStore,
);
});
test('should initialize correctly', () => {
expect(lspService).toBeDefined();
});
test('should detect languages from workspace files', async () => {
// 这个测试需要修改,因为我们无法直接访问私有方法
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
test('should merge built-in presets with user configs', async () => {
await lspService.discoverAndPrepare();
const status = lspService.getStatus();
// 检查服务是否已准备就绪
expect(status).toBeDefined();
});
});
// 注意:实际的单元测试需要适当的测试框架配置
// 这里只是一个结构示例

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,6 @@ import process from 'node:process';
import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
@@ -576,6 +575,7 @@ export const AppContainer = (props: AppContainerProps) => {
config.getExtensionContextFilePaths(),
config.isTrustedFolder(),
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
);
config.setUserMemory(memoryContent);
@@ -1196,19 +1196,6 @@ export const AppContainer = (props: AppContainerProps) => {
isApprovalModeDialogOpen ||
isResumeDialogOpen;
const {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
settings,
streamingState,
history: historyManager.history,
sessionStats,
});
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
@@ -1305,8 +1292,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
[
isThemeDialogOpen,
@@ -1397,8 +1382,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
);
@@ -1439,10 +1422,6 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
}),
[
handleThemeSelect,
@@ -1478,10 +1457,6 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
],
);

View File

@@ -1,61 +0,0 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { t } from '../i18n/index.js';
import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
const FEEDBACK_OPTIONS = {
GOOD: 1,
BAD: 2,
NOT_SURE: 3,
} as const;
const FEEDBACK_OPTION_KEYS = {
[FEEDBACK_OPTIONS.GOOD]: '1',
[FEEDBACK_OPTIONS.BAD]: '2',
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
} as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
const uiActions = useUIActions();
useKeypress(
(key) => {
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
} else {
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text color="cyan"> </Text>
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
</Box>
<Box marginTop={1}>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]}:{' '}
</Text>
<Text>{t('Good')}</Text>
<Text> </Text>
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">{t('Any other key')}: </Text>
<Text>{t('Not Sure Yet')}</Text>
</Box>
</Box>
);
};

View File

@@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ContentGeneratorConfig,
ModelProvidersConfig,
} from '@qwen-code/qwen-code-core';
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
@@ -218,19 +214,11 @@ export const useAuthCommand = (
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
// Pass settings.model.generationConfig to updateCredentials so it can be merged
// after clearing provider-sourced config. This ensures settings.json generationConfig
// fields (e.g., samplingParams, timeout) are preserved.
const settingsGenerationConfig = settings.merged.model
?.generationConfig as Partial<ContentGeneratorConfig> | undefined;
config.updateCredentials(
{
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
},
settingsGenerationConfig,
);
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
await performAuth(authType, credentials);
}
return;
@@ -238,13 +226,7 @@ export const useAuthCommand = (
await performAuth(authType);
},
[
config,
performAuth,
isProviderManagedModel,
onAuthError,
settings.merged.model?.generationConfig,
],
[config, performAuth, isProviderManagedModel, onAuthError],
);
const openAuthDialog = useCallback(() => {

View File

@@ -54,7 +54,9 @@ describe('directoryCommand', () => {
services: {
config: mockConfig,
settings: {
merged: {},
merged: {
memoryDiscoveryMaxDirs: 1000,
},
},
},
ui: {

View File

@@ -119,6 +119,8 @@ export const directoryCommand: SlashCommand = {
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.context?.discoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);

View File

@@ -299,7 +299,9 @@ describe('memoryCommand', () => {
services: {
config: mockConfig,
settings: {
merged: {},
merged: {
memoryDiscoveryMaxDirs: 1000,
},
} as LoadedSettings,
},
});

View File

@@ -315,6 +315,8 @@ export const memoryCommand: SlashCommand = {
config.getFolderTrust(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.context?.discoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);

View File

@@ -26,7 +26,6 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';
export const Composer = () => {
@@ -135,8 +134,6 @@ export const Composer = () => {
</OverflowProvider>
)}
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}

View File

@@ -33,9 +33,6 @@ vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
const mockSlashCommands: SlashCommand[] = [
{
@@ -281,7 +278,7 @@ describe('InputPrompt', () => {
unmount();
});
it('should call completion.navigateUp for up arrow when suggestions are showing', async () => {
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -296,22 +293,19 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test up arrow for completion navigation
// Test up arrow
stdin.write('\u001B[A'); // Up arrow
await wait();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
// Ctrl+P should navigate history, not completion
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for down arrow when suggestions are showing', async () => {
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
@@ -325,17 +319,14 @@ describe('InputPrompt', () => {
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
// Test down arrow for completion navigation
// Test down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
// Ctrl+N should navigate history, not completion
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
unmount();
});
@@ -773,8 +764,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -802,8 +791,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -831,8 +818,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -860,8 +845,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -889,8 +872,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -919,8 +900,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -948,8 +927,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -978,8 +955,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1008,8 +983,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1038,8 +1011,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1068,8 +1039,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1100,8 +1069,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1130,8 +1097,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();
@@ -1162,8 +1127,6 @@ describe('InputPrompt', () => {
mockCommandContext,
false,
expect.any(Object),
// active parameter: completion enabled when not just navigated history
true,
);
unmount();

View File

@@ -36,8 +36,6 @@ import {
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
@@ -102,7 +100,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isEmbeddedShellFocused,
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -138,8 +135,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandContext,
reverseSearchActive,
config,
// Suppress completion when history navigation just occurred
!justNavigatedHistory,
);
const reverseSearchCompletion = useReverseSearchCompletion(
@@ -224,9 +219,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const inputHistory = useInputHistory({
userMessages,
onSubmit: handleSubmitAndClear,
// History navigation (Ctrl+P/N) now always works since completion navigation
// only uses arrow keys. Only disable in shell mode.
isActive: !shellModeActive,
isActive:
(!completion.showSuggestions || completion.suggestions.length === 1) &&
!shellModeActive,
currentQuery: buffer.text,
onChange: customSetTextAndResetCompletionSignal,
});
@@ -331,14 +326,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
}
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount > 0 || showEscapePrompt) {
@@ -683,7 +670,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
recentPasteTime,
commandSearchActive,
commandSearchCompletion,
uiState,
],
);

View File

@@ -275,7 +275,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
persistModelSelection(settings, effectiveModelId);
persistAuthTypeSelection(settings, effectiveAuthType);
const baseUrl = after?.baseUrl ?? t('(default)');
const baseUrl = after?.baseUrl ?? '(default)';
const maskedKey = maskApiKey(after?.apiKey);
uiState?.historyManager.addItem(
{
@@ -322,7 +322,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
<>
<ConfigRow
label="Base URL"
value={effectiveConfig?.baseUrl ?? t('(default)')}
value={effectiveConfig?.baseUrl ?? ''}
badge={formatSourceBadge(sources['baseUrl'])}
/>
<ConfigRow

View File

@@ -1331,7 +1331,9 @@ describe('SettingsDialog', () => {
truncateToolOutputThreshold: 50000,
truncateToolOutputLines: 1000,
},
context: {},
context: {
discoveryMaxDirs: 500,
},
model: {
maxSessionTurns: 100,
skipNextSpeakerCheck: false,
@@ -1464,6 +1466,7 @@ describe('SettingsDialog', () => {
disableFuzzySearch: true,
},
loadMemoryFromIncludeDirectories: true,
discoveryMaxDirs: 100,
},
});
const onSelect = vi.fn();

View File

@@ -66,10 +66,6 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);

View File

@@ -126,8 +126,6 @@ export interface UIState {
// Subagent dialogs
isSubagentCreateDialogOpen: boolean;
isAgentsManagerDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -45,8 +45,6 @@ export function useCommandCompletion(
commandContext: CommandContext,
reverseSearchActive: boolean = false,
config?: Config,
// When false, suppresses showing suggestions (e.g., after history navigation)
active: boolean = true,
): UseCommandCompletionReturn {
const {
suggestions,
@@ -154,11 +152,7 @@ export function useCommandCompletion(
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
useEffect(() => {
if (
completionMode === CompletionMode.IDLE ||
reverseSearchActive ||
!active
) {
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
resetCompletionState();
return;
}
@@ -169,7 +163,6 @@ export function useCommandCompletion(
suggestions.length,
isLoadingSuggestions,
reverseSearchActive,
active,
resetCompletionState,
setShowSuggestions,
]);

View File

@@ -1,178 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import * as fs from 'node:fs';
import {
type Config,
logUserFeedback,
UserFeedbackEvent,
type UserFeedbackRating,
isNodeError,
AuthType,
} from '@qwen-code/qwen-code-core';
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
import {
SettingScope,
type LoadedSettings,
USER_SETTINGS_PATH,
} from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import stripJsonComments from 'strip-json-comments';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
// Fatigue mechanism constants
const FEEDBACK_COOLDOWN_HOURS = 24; // Hours to wait before showing feedback dialog again
/**
* Check if the last message in the conversation history is an AI response
*/
const lastMessageIsAIResponse = (history: HistoryItem[]): boolean =>
history.length > 0 && history[history.length - 1].type === MessageType.GEMINI;
/**
* Read feedbackLastShownTimestamp directly from the user settings file
*/
const getFeedbackLastShownTimestampFromFile = (): number => {
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const content = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
const settings = JSON.parse(stripJsonComments(content));
return settings?.ui?.feedbackLastShownTimestamp ?? 0;
}
} catch (error) {
if (isNodeError(error) && error.code !== 'ENOENT') {
console.warn(
'Failed to read feedbackLastShownTimestamp from settings file:',
error,
);
}
}
return 0;
};
/**
* Check if we should show the feedback dialog based on fatigue mechanism
*/
const shouldShowFeedbackBasedOnFatigue = (): boolean => {
const feedbackLastShownTimestamp = getFeedbackLastShownTimestampFromFile();
const now = Date.now();
const timeSinceLastShown = now - feedbackLastShownTimestamp;
const cooldownMs = FEEDBACK_COOLDOWN_HOURS * 60 * 60 * 1000;
return timeSinceLastShown >= cooldownMs;
};
/**
* Check if the session meets the minimum requirements for showing feedback
* Either tool calls > 10 OR user messages > 5
*/
const meetsMinimumSessionRequirements = (
sessionStats: SessionStatsState,
): boolean => {
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
const userMessagesCount = sessionStats.promptCount;
return (
toolCallsCount > MIN_TOOL_CALLS || userMessagesCount > MIN_USER_MESSAGES
);
};
export interface UseFeedbackDialogProps {
config: Config;
settings: LoadedSettings;
streamingState: StreamingState;
history: HistoryItem[];
sessionStats: SessionStatsState;
}
export const useFeedbackDialog = ({
config,
settings,
streamingState,
history,
sessionStats,
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
// Record the timestamp when feedback dialog is shown (fire and forget)
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
}, [settings]);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const submitFeedback = useCallback(
(rating: number) => {
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, closeFeedbackDialog],
);
useEffect(() => {
const checkAndShowFeedback = () => {
if (streamingState === StreamingState.Idle && history.length > 0) {
// Show feedback dialog if:
// 1. User is authenticated via QWEN_OAUTH
// 2. Qwen logger is enabled (required for feedback submission)
// 3. User feedback is enabled in settings
// 4. The last message is an AI response
// 5. Random chance (25% probability)
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
if (
config.getAuthType() !== AuthType.QWEN_OAUTH ||
!config.getUsageStatisticsEnabled() ||
settings.merged.ui?.enableUserFeedback === false ||
!lastMessageIsAIResponse(history) ||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
!meetsMinimumSessionRequirements(sessionStats)
) {
return;
}
// Check fatigue mechanism (synchronous)
if (shouldShowFeedbackBasedOnFatigue()) {
openFeedbackDialog();
}
}
};
checkAndShowFeedback();
}, [
streamingState,
history,
sessionStats,
isFeedbackDialogOpen,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
submitFeedback,
};
};

View File

@@ -38,10 +38,10 @@ describe('keyMatchers', () => {
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
[Command.ACCEPT_SUGGESTION]: (key: Key) =>
key.name === 'tab' || (key.name === 'return' && !key.ctrl),
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
[Command.COMPLETION_UP]: (key: Key) => key.name === 'up',
[Command.COMPLETION_DOWN]: (key: Key) => key.name === 'down',
[Command.COMPLETION_UP]: (key: Key) =>
key.name === 'up' || (key.ctrl && key.name === 'p'),
[Command.COMPLETION_DOWN]: (key: Key) =>
key.name === 'down' || (key.ctrl && key.name === 'n'),
[Command.ESCAPE]: (key: Key) => key.name === 'escape',
[Command.SUBMIT]: (key: Key) =>
key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
@@ -164,26 +164,14 @@ describe('keyMatchers', () => {
negative: [createKey('return', { ctrl: true }), createKey('space')],
},
{
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
command: Command.COMPLETION_UP,
positive: [createKey('up')],
negative: [
createKey('p'),
createKey('down'),
createKey('p', { ctrl: true }),
],
positive: [createKey('up'), createKey('p', { ctrl: true })],
negative: [createKey('p'), createKey('down')],
},
{
// Completion navigation only uses arrow keys (not Ctrl+P/N)
// to allow Ctrl+P/N to always navigate history
command: Command.COMPLETION_DOWN,
positive: [createKey('down')],
negative: [
createKey('n'),
createKey('up'),
createKey('n', { ctrl: true }),
],
positive: [createKey('down'), createKey('n', { ctrl: true })],
negative: [createKey('n'), createKey('up')],
},
// Text input

View File

@@ -1,722 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
AuthType,
resolveModelConfig,
type ProviderModelConfig,
} from '@qwen-code/qwen-code-core';
import {
getAuthTypeFromEnv,
resolveCliGenerationConfig,
} from './modelConfigUtils.js';
import type { Settings } from '../config/settings.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
resolveModelConfig: vi.fn(),
};
});
describe('modelConfigUtils', () => {
describe('getAuthTypeFromEnv', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
// Start with a clean env - getAuthTypeFromEnv only checks auth-related vars
process.env = {};
});
afterEach(() => {
process.env = originalEnv;
});
it('should return USE_OPENAI when all OpenAI env vars are set', () => {
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_OPENAI);
});
it('should return undefined when OpenAI env vars are incomplete', () => {
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
// Missing OPENAI_BASE_URL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return QWEN_OAUTH when QWEN_OAUTH is set', () => {
process.env['QWEN_OAUTH'] = 'true';
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
});
it('should return USE_GEMINI when Gemini env vars are set', () => {
process.env['GEMINI_API_KEY'] = 'test-key';
process.env['GEMINI_MODEL'] = 'gemini-pro';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_GEMINI);
});
it('should return undefined when Gemini env vars are incomplete', () => {
process.env['GEMINI_API_KEY'] = 'test-key';
// Missing GEMINI_MODEL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return USE_VERTEX_AI when Google env vars are set', () => {
process.env['GOOGLE_API_KEY'] = 'test-key';
process.env['GOOGLE_MODEL'] = 'vertex-model';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_VERTEX_AI);
});
it('should return undefined when Google env vars are incomplete', () => {
process.env['GOOGLE_API_KEY'] = 'test-key';
// Missing GOOGLE_MODEL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should return USE_ANTHROPIC when Anthropic env vars are set', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-key';
process.env['ANTHROPIC_MODEL'] = 'claude-3';
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_ANTHROPIC);
});
it('should return undefined when Anthropic env vars are incomplete', () => {
process.env['ANTHROPIC_API_KEY'] = 'test-key';
process.env['ANTHROPIC_MODEL'] = 'claude-3';
// Missing ANTHROPIC_BASE_URL
expect(getAuthTypeFromEnv()).toBeUndefined();
});
it('should prioritize QWEN_OAUTH over other auth types when explicitly set', () => {
process.env['QWEN_OAUTH'] = 'true';
process.env['OPENAI_API_KEY'] = 'test-key';
process.env['OPENAI_MODEL'] = 'gpt-4';
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
// QWEN_OAUTH is checked first, so it should be returned even when other auth vars are set
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
});
it('should return undefined when no auth env vars are set', () => {
expect(getAuthTypeFromEnv()).toBeUndefined();
});
});
describe('resolveCliGenerationConfig', () => {
const originalEnv = process.env;
const originalConsoleWarn = console.warn;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
console.warn = vi.fn();
});
afterEach(() => {
process.env = originalEnv;
console.warn = originalConsoleWarn;
vi.clearAllMocks();
});
function makeMockSettings(overrides?: Partial<Settings>): Settings {
return {
model: { name: 'default-model' },
security: {
auth: {
apiKey: 'settings-api-key',
baseUrl: 'https://settings.example.com',
},
},
...overrides,
} as Settings;
}
it('should resolve config from argv with highest precedence', () => {
const argv = {
model: 'argv-model',
openaiApiKey: 'argv-key',
openaiBaseUrl: 'https://argv.example.com',
};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'argv-model',
apiKey: 'argv-key',
baseUrl: 'https://argv.example.com',
},
sources: {
model: { kind: 'cli', detail: '--model' },
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('argv-model');
expect(result.apiKey).toBe('argv-key');
expect(result.baseUrl).toBe('https://argv.example.com');
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
cli: {
model: 'argv-model',
apiKey: 'argv-key',
baseUrl: 'https://argv.example.com',
},
}),
);
});
it('should resolve config from settings when argv is not provided', () => {
const argv = {};
const settings = makeMockSettings({
model: { name: 'settings-model' },
security: {
auth: {
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'settings-model',
apiKey: 'settings-key',
baseUrl: 'https://settings.example.com',
},
sources: {
model: { kind: 'settings', detail: 'model.name' },
apiKey: { kind: 'settings', detail: 'security.auth.apiKey' },
baseUrl: { kind: 'settings', detail: 'security.auth.baseUrl' },
},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('settings-model');
expect(result.apiKey).toBe('settings-key');
expect(result.baseUrl).toBe('https://settings.example.com');
});
it('should merge generationConfig from settings', () => {
const argv = {};
const settings = makeMockSettings({
model: {
name: 'test-model',
generationConfig: {
samplingParams: {
temperature: 0.7,
max_tokens: 1000,
},
timeout: 5000,
} as Record<string, unknown>,
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
samplingParams: {
temperature: 0.7,
max_tokens: 1000,
},
timeout: 5000,
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.samplingParams?.temperature).toBe(0.7);
expect(result.generationConfig.samplingParams?.max_tokens).toBe(1000);
expect(result.generationConfig.timeout).toBe(5000);
});
it('should resolve OpenAI logging from argv', () => {
const argv = {
openaiLogging: true,
openaiLoggingDir: '/custom/log/dir',
};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(true);
expect(result.generationConfig.openAILoggingDir).toBe('/custom/log/dir');
});
it('should resolve OpenAI logging from settings when argv is undefined', () => {
const argv = {};
const settings = makeMockSettings({
model: {
name: 'test-model',
enableOpenAILogging: true,
openAILoggingDir: '/settings/log/dir',
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(true);
expect(result.generationConfig.openAILoggingDir).toBe(
'/settings/log/dir',
);
});
it('should default OpenAI logging to false when not provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig.enableOpenAILogging).toBe(false);
});
it('should find modelProvider from settings when authType and model match', () => {
const argv = { model: 'provider-model' };
const modelProvider: ProviderModelConfig = {
id: 'provider-model',
name: 'Provider Model',
generationConfig: {
samplingParams: { temperature: 0.8 },
},
};
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: [modelProvider],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'provider-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider,
}),
);
});
it('should find modelProvider from settings.model.name when argv.model is not provided', () => {
const argv = {};
const modelProvider: ProviderModelConfig = {
id: 'settings-model',
name: 'Settings Model',
generationConfig: {
samplingParams: { temperature: 0.9 },
},
};
const settings = makeMockSettings({
model: { name: 'settings-model' },
modelProviders: {
[AuthType.USE_OPENAI]: [modelProvider],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'settings-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider,
}),
);
});
it('should not find modelProvider when authType is undefined', () => {
const argv = { model: 'test-model' };
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: [{ id: 'test-model', name: 'Test Model' }],
},
});
const selectedAuthType = undefined;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: undefined,
}),
);
});
it('should not find modelProvider when modelProviders is not an array', () => {
const argv = { model: 'test-model' };
const settings = makeMockSettings({
modelProviders: {
[AuthType.USE_OPENAI]: null as unknown as ProviderModelConfig[],
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: undefined,
}),
);
});
it('should log warnings from resolveModelConfig', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: ['Warning 1', 'Warning 2'],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(console.warn).toHaveBeenCalledWith('Warning 1');
expect(console.warn).toHaveBeenCalledWith('Warning 2');
});
it('should use custom env when provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
const customEnv = {
OPENAI_API_KEY: 'custom-key',
OPENAI_MODEL: 'custom-model',
};
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'custom-model',
apiKey: 'custom-key',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
env: customEnv,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
env: customEnv,
}),
);
});
it('should use process.env when env is not provided', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
}),
);
});
it('should return empty strings for missing model, apiKey, and baseUrl', () => {
const argv = {};
const settings = makeMockSettings();
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('');
expect(result.apiKey).toBe('');
expect(result.baseUrl).toBe('');
});
it('should merge resolved config with logging settings', () => {
const argv = {
openaiLogging: true,
};
const settings = makeMockSettings({
model: {
name: 'test-model',
generationConfig: {
timeout: 5000,
} as Record<string, unknown>,
},
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: 'test-model',
apiKey: 'test-key',
baseUrl: 'https://test.com',
samplingParams: { temperature: 0.5 },
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.generationConfig).toEqual({
model: 'test-model',
apiKey: 'test-key',
baseUrl: 'https://test.com',
samplingParams: { temperature: 0.5 },
enableOpenAILogging: true,
openAILoggingDir: undefined,
});
});
it('should handle settings without model property', () => {
const argv = {};
const settings = makeMockSettings({
model: undefined as unknown as Settings['model'],
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
const result = resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(result.model).toBe('');
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
model: undefined,
}),
}),
);
});
it('should handle settings without security.auth property', () => {
const argv = {};
const settings = makeMockSettings({
security: undefined,
});
const selectedAuthType = AuthType.USE_OPENAI;
vi.mocked(resolveModelConfig).mockReturnValue({
config: {
model: '',
apiKey: '',
baseUrl: '',
},
sources: {},
warnings: [],
});
resolveCliGenerationConfig({
argv,
settings,
selectedAuthType,
});
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
apiKey: undefined,
baseUrl: undefined,
}),
}),
);
});
});
});

View File

@@ -44,31 +44,20 @@ export interface ResolvedCliGenerationConfig {
}
export function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['OPENAI_API_KEY']) {
return AuthType.USE_OPENAI;
}
if (process.env['QWEN_OAUTH']) {
return AuthType.QWEN_OAUTH;
}
if (
process.env['OPENAI_API_KEY'] &&
process.env['OPENAI_MODEL'] &&
process.env['OPENAI_BASE_URL']
) {
return AuthType.USE_OPENAI;
}
if (process.env['GEMINI_API_KEY'] && process.env['GEMINI_MODEL']) {
if (process.env['GEMINI_API_KEY']) {
return AuthType.USE_GEMINI;
}
if (process.env['GOOGLE_API_KEY'] && process.env['GOOGLE_MODEL']) {
if (process.env['GOOGLE_API_KEY']) {
return AuthType.USE_VERTEX_AI;
}
if (
process.env['ANTHROPIC_API_KEY'] &&
process.env['ANTHROPIC_MODEL'] &&
process.env['ANTHROPIC_BASE_URL']
) {
if (process.env['ANTHROPIC_API_KEY']) {
return AuthType.USE_ANTHROPIC;
}

View File

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

View File

@@ -61,6 +61,11 @@ import { ToolRegistry } from '../tools/tool-registry.js';
import { WebFetchTool } from '../tools/web-fetch.js';
import { WebSearchTool } from '../tools/web-search/index.js';
import { WriteFileTool } from '../tools/write-file.js';
import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js';
import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js';
import { LspFindReferencesTool } from '../tools/lsp-find-references.js';
import { LspTool } from '../tools/lsp.js';
import type { LspClient } from '../lsp/types.js';
// Other modules
import { ideContextStore } from '../ide/ideContext.js';
@@ -287,6 +292,12 @@ export interface ConfigParameters {
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
lsp?: {
enabled?: boolean;
allowed?: string[];
excluded?: string[];
};
lspClient?: LspClient;
userMemory?: string;
geminiMdFileCount?: number;
approvalMode?: ApprovalMode;
@@ -429,6 +440,10 @@ export class Config {
private readonly toolCallCommand: string | undefined;
private readonly mcpServerCommand: string | undefined;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly lspEnabled: boolean;
private readonly lspAllowed?: string[];
private readonly lspExcluded?: string[];
private lspClient?: LspClient;
private sessionSubagents: SubagentConfig[];
private userMemory: string;
private sdkMode: boolean;
@@ -534,6 +549,10 @@ export class Config {
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.lspEnabled = params.lsp?.enabled ?? false;
this.lspAllowed = params.lsp?.allowed?.filter(Boolean);
this.lspExcluded = params.lsp?.excluded?.filter(Boolean);
this.lspClient = params.lspClient;
this.sessionSubagents = params.sessionSubagents ?? [];
this.sdkMode = params.sdkMode ?? false;
this.userMemory = params.userMemory ?? '';
@@ -708,15 +727,12 @@ export class Config {
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
* Delegates to ModelsConfig.
*/
updateCredentials(
credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
this._modelsConfig.updateCredentials(credentials, settingsGenerationConfig);
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
this._modelsConfig.updateCredentials(credentials);
}
/**
@@ -1034,6 +1050,32 @@ export class Config {
this.mcpServers = { ...this.mcpServers, ...servers };
}
isLspEnabled(): boolean {
return this.lspEnabled;
}
getLspAllowed(): string[] | undefined {
return this.lspAllowed;
}
getLspExcluded(): string[] | undefined {
return this.lspExcluded;
}
getLspClient(): LspClient | undefined {
return this.lspClient;
}
/**
* Allows wiring an LSP client after Config construction but before initialize().
*/
setLspClient(client: LspClient | undefined): void {
if (this.initialized) {
throw new Error('Cannot set LSP client after initialization');
}
this.lspClient = client;
}
getSessionSubagents(): SubagentConfig[] {
return this.sessionSubagents;
}
@@ -1541,6 +1583,14 @@ export class Config {
if (this.getWebSearchConfig()) {
registerCoreTool(WebSearchTool, this);
}
if (this.isLspEnabled() && this.getLspClient()) {
// Register the unified LSP tool (recommended)
registerCoreTool(LspTool, this);
// Keep legacy tools for backward compatibility
registerCoreTool(LspGoToDefinitionTool, this);
registerCoreTool(LspFindReferencesTool, this);
registerCoreTool(LspWorkspaceSymbolTool, this);
}
await registry.discoverAllTools();
console.debug('ToolRegistry created', registry.getAllToolNames());

View File

@@ -111,6 +111,7 @@ export * from './skills/index.js';
// Export prompt logic
export * from './prompts/mcp-prompts.js';
export * from './lsp/types.js';
// Export specific tool logic
export * from './tools/read-file.js';
@@ -125,6 +126,8 @@ export * from './tools/memoryTool.js';
export * from './tools/shell.js';
export * from './tools/web-search/index.js';
export * from './tools/read-many-files.js';
export * from './tools/lsp-go-to-definition.js';
export * from './tools/lsp-find-references.js';
export * from './tools/mcp-client.js';
export * from './tools/mcp-client-manager.js';
export * from './tools/mcp-tool.js';

View File

@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface LspPosition {
line: number;
character: number;
}
export interface LspRange {
start: LspPosition;
end: LspPosition;
}
export interface LspLocation {
uri: string;
range: LspRange;
}
export interface LspLocationWithServer extends LspLocation {
serverName?: string;
}
export interface LspSymbolInformation {
name: string;
kind?: string;
location: LspLocation;
containerName?: string;
serverName?: string;
}
export interface LspReference extends LspLocationWithServer {
readonly serverName?: string;
}
export interface LspDefinition extends LspLocationWithServer {
readonly serverName?: string;
}
/**
* Hover result containing documentation or type information.
*/
export interface LspHoverResult {
/** The hover content as a string (normalized from MarkupContent/MarkedString). */
contents: string;
/** Optional range that the hover applies to. */
range?: LspRange;
/** The LSP server that provided this result. */
serverName?: string;
}
/**
* Call hierarchy item representing a function, method, or callable.
*/
export interface LspCallHierarchyItem {
/** The name of this item. */
name: string;
/** The kind of this item (function, method, constructor, etc.) as readable string. */
kind?: string;
/** The raw numeric SymbolKind from LSP, preserved for server communication. */
rawKind?: number;
/** Additional details like signature or file path. */
detail?: string;
/** The URI of the document containing this item. */
uri: string;
/** The full range of this item. */
range: LspRange;
/** The range that should be selected when navigating to this item. */
selectionRange: LspRange;
/** Opaque data used by the server for subsequent calls. */
data?: unknown;
/** The LSP server that provided this item. */
serverName?: string;
}
/**
* Incoming call representing a function that calls the target.
*/
export interface LspCallHierarchyIncomingCall {
/** The caller item. */
from: LspCallHierarchyItem;
/** The ranges where the call occurs within the caller. */
fromRanges: LspRange[];
}
/**
* Outgoing call representing a function called by the target.
*/
export interface LspCallHierarchyOutgoingCall {
/** The callee item. */
to: LspCallHierarchyItem;
/** The ranges where the call occurs within the caller. */
fromRanges: LspRange[];
}
export interface LspClient {
/**
* Search for symbols across the workspace.
*/
workspaceSymbols(
query: string,
limit?: number,
): Promise<LspSymbolInformation[]>;
/**
* Get hover information (documentation, type info) for a symbol.
*/
hover(
location: LspLocation,
serverName?: string,
): Promise<LspHoverResult | null>;
/**
* Get all symbols in a document.
*/
documentSymbols(
uri: string,
serverName?: string,
limit?: number,
): Promise<LspSymbolInformation[]>;
/**
* Find where a symbol is defined.
*/
definitions(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspDefinition[]>;
/**
* Find implementations of an interface or abstract method.
*/
implementations(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspDefinition[]>;
/**
* Find all references to a symbol.
*/
references(
location: LspLocation,
serverName?: string,
includeDeclaration?: boolean,
limit?: number,
): Promise<LspReference[]>;
/**
* Prepare call hierarchy item at a position (functions/methods).
*/
prepareCallHierarchy(
location: LspLocation,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyItem[]>;
/**
* Find all functions/methods that call the given function.
*/
incomingCalls(
item: LspCallHierarchyItem,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyIncomingCall[]>;
/**
* Find all functions/methods called by the given function.
*/
outgoingCalls(
item: LspCallHierarchyItem,
serverName?: string,
limit?: number,
): Promise<LspCallHierarchyOutgoingCall[]>;
}

View File

@@ -102,14 +102,16 @@ export const QWEN_OAUTH_ALLOWED_MODELS = [
export const QWEN_OAUTH_MODELS: ModelConfig[] = [
{
id: 'coder-model',
name: 'coder-model',
description: 'The latest Qwen Coder model from Alibaba Cloud ModelStudio',
name: 'Qwen Coder',
description:
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)',
capabilities: { vision: false },
},
{
id: 'vision-model',
name: 'vision-model',
description: 'The latest Qwen Vision model from Alibaba Cloud ModelStudio',
name: 'Qwen Vision',
description:
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)',
capabilities: { vision: true },
},
];

View File

@@ -191,7 +191,7 @@ describe('ModelsConfig', () => {
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
});
it('should use provider config when modelId exists in registry even after updateCredentials', () => {
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
@@ -213,7 +213,7 @@ describe('ModelsConfig', () => {
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'custom-model',
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -235,30 +235,30 @@ describe('ModelsConfig', () => {
},
});
// User manually updates credentials via updateCredentials.
// Note: In practice, handleAuthSelect prevents using a modelId that matches a provider model,
// but if syncAfterAuthRefresh is called with a modelId that exists in registry,
// we should use provider config.
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
// User manually updates the model via updateCredentials (e.g. key prompt flow).
// Even if the model ID matches a modelProviders entry, we must not apply provider defaults
// that would overwrite settings.model.generationConfig.
modelsConfig.updateCredentials({ model: 'model-a' });
// syncAfterAuthRefresh with a modelId that exists in registry should use provider config
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('model-a');
// Provider config should be applied
expect(gc.samplingParams?.temperature).toBe(0.1);
expect(gc.samplingParams?.max_tokens).toBe(123);
expect(gc.timeout).toBe(111);
expect(gc.maxRetries).toBe(1);
expect(gc.samplingParams?.temperature).toBe(0.9);
expect(gc.samplingParams?.max_tokens).toBe(999);
expect(gc.timeout).toBe(9999);
expect(gc.maxRetries).toBe(9);
});
it('should preserve settings generationConfig when modelId does not exist in registry', () => {
it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'provider-model',
name: 'Provider Model',
id: 'model-a',
name: 'Model A',
baseUrl: 'https://api.example.com/v1',
envKey: 'API_KEY_A',
generationConfig: {
@@ -270,12 +270,11 @@ describe('ModelsConfig', () => {
],
};
// Simulate settings with a custom model (not in registry)
const modelsConfig = new ModelsConfig({
initialAuthType: AuthType.USE_OPENAI,
modelProvidersConfig,
generationConfig: {
model: 'custom-model',
model: 'model-a',
samplingParams: { temperature: 0.9, max_tokens: 999 },
timeout: 9999,
maxRetries: 9,
@@ -297,21 +296,25 @@ describe('ModelsConfig', () => {
},
});
// User manually sets credentials for a custom model (not in registry)
modelsConfig.updateCredentials({
apiKey: 'manual-key',
baseUrl: 'https://manual.example.com/v1',
model: 'custom-model',
model: 'model-a',
});
// First auth refresh - modelId doesn't exist in registry, so credentials should be preserved
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
// First auth refresh
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
// Second auth refresh should still preserve settings generationConfig
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
modelsConfig.syncAfterAuthRefresh(
AuthType.USE_OPENAI,
modelsConfig.getModel(),
);
const gc = currentGenerationConfig(modelsConfig);
expect(gc.model).toBe('custom-model');
// Settings-sourced generation config should be preserved since modelId doesn't exist in registry
expect(gc.model).toBe('model-a');
expect(gc.samplingParams?.temperature).toBe(0.9);
expect(gc.samplingParams?.max_tokens).toBe(999);
expect(gc.timeout).toBe(9999);
@@ -678,120 +681,4 @@ describe('ModelsConfig', () => {
expect(modelsConfig.getModel()).toBe('updated-model');
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
});
describe('getAllAvailableModels', () => {
it('should return all models across all authTypes', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'openai-model-1',
name: 'OpenAI Model 1',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
{
id: 'openai-model-2',
name: 'OpenAI Model 2',
baseUrl: 'https://api.openai.com/v1',
envKey: 'OPENAI_API_KEY',
},
],
anthropic: [
{
id: 'anthropic-model-1',
name: 'Anthropic Model 1',
baseUrl: 'https://api.anthropic.com/v1',
envKey: 'ANTHROPIC_API_KEY',
},
],
gemini: [
{
id: 'gemini-model-1',
name: 'Gemini Model 1',
baseUrl: 'https://generativelanguage.googleapis.com/v1',
envKey: 'GEMINI_API_KEY',
},
],
};
const modelsConfig = new ModelsConfig({
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
// Should include qwen-oauth models (hard-coded)
const qwenModels = allModels.filter(
(m) => m.authType === AuthType.QWEN_OAUTH,
);
expect(qwenModels.length).toBeGreaterThan(0);
// Should include openai models
const openaiModels = allModels.filter(
(m) => m.authType === AuthType.USE_OPENAI,
);
expect(openaiModels.length).toBe(2);
expect(openaiModels.map((m) => m.id)).toContain('openai-model-1');
expect(openaiModels.map((m) => m.id)).toContain('openai-model-2');
// Should include anthropic models
const anthropicModels = allModels.filter(
(m) => m.authType === AuthType.USE_ANTHROPIC,
);
expect(anthropicModels.length).toBe(1);
expect(anthropicModels[0].id).toBe('anthropic-model-1');
// Should include gemini models
const geminiModels = allModels.filter(
(m) => m.authType === AuthType.USE_GEMINI,
);
expect(geminiModels.length).toBe(1);
expect(geminiModels[0].id).toBe('gemini-model-1');
});
it('should return empty array when no models are registered', () => {
const modelsConfig = new ModelsConfig();
const allModels = modelsConfig.getAllAvailableModels();
// Should still include qwen-oauth models (hard-coded)
expect(allModels.length).toBeGreaterThan(0);
const qwenModels = allModels.filter(
(m) => m.authType === AuthType.QWEN_OAUTH,
);
expect(qwenModels.length).toBeGreaterThan(0);
});
it('should return models with correct structure', () => {
const modelProvidersConfig: ModelProvidersConfig = {
openai: [
{
id: 'test-model',
name: 'Test Model',
description: 'A test model',
baseUrl: 'https://api.example.com/v1',
envKey: 'TEST_API_KEY',
capabilities: {
vision: true,
},
},
],
};
const modelsConfig = new ModelsConfig({
modelProvidersConfig,
});
const allModels = modelsConfig.getAllAvailableModels();
const testModel = allModels.find((m) => m.id === 'test-model');
expect(testModel).toBeDefined();
expect(testModel?.id).toBe('test-model');
expect(testModel?.label).toBe('Test Model');
expect(testModel?.description).toBe('A test model');
expect(testModel?.authType).toBe(AuthType.USE_OPENAI);
expect(testModel?.isVision).toBe(true);
expect(testModel?.capabilities?.vision).toBe(true);
});
});
});

View File

@@ -203,18 +203,6 @@ export class ModelsConfig {
return this.modelRegistry.getModelsForAuthType(authType);
}
/**
* Get all available models across all authTypes
*/
getAllAvailableModels(): AvailableModel[] {
const allModels: AvailableModel[] = [];
for (const authType of Object.values(AuthType)) {
const models = this.modelRegistry.getModelsForAuthType(authType);
allModels.push(...models);
}
return allModels;
}
/**
* Check if a model exists for the given authType
*/
@@ -319,33 +307,6 @@ export class ModelsConfig {
return this.generationConfigSources;
}
/**
* Merge settings generation config, preserving existing values.
* Used when provider-sourced config is cleared but settings should still apply.
*/
mergeSettingsGenerationConfig(
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
if (!settingsGenerationConfig) {
return;
}
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
if (
!(field in this._generationConfig) &&
field in settingsGenerationConfig
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this._generationConfig as any)[field] =
settingsGenerationConfig[field];
this.generationConfigSources[field] = {
kind: 'settings',
detail: `model.generationConfig.${field}`,
};
}
}
}
/**
* Update credentials in generation config.
* Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials.
@@ -353,20 +314,12 @@ export class ModelsConfig {
* When credentials are manually set, we clear all provider-sourced configuration
* to maintain provider atomicity (either fully applied or not at all).
* Other layers (CLI, env, settings, defaults) will participate in resolve.
*
* @param settingsGenerationConfig Optional generation config from settings.json
* to merge after clearing provider-sourced config.
* This ensures settings.model.generationConfig fields
* (e.g., samplingParams, timeout) are preserved.
*/
updateCredentials(
credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
): void {
updateCredentials(credentials: {
apiKey?: string;
baseUrl?: string;
model?: string;
}): void {
/**
* If any fields are updated here, we treat the resulting config as manually overridden
* and avoid applying modelProvider defaults during the next auth refresh.
@@ -406,14 +359,6 @@ export class ModelsConfig {
this.strictModelProviderSelection = false;
// Clear apiKeyEnvKey to prevent validation from requiring environment variable
this._generationConfig.apiKeyEnvKey = undefined;
// After clearing provider-sourced config, merge settings.model.generationConfig
// to ensure fields like samplingParams, timeout, etc. are preserved.
// This follows the resolution strategy where settings.model.generationConfig
// has lower priority than programmatic overrides but should still be applied.
if (settingsGenerationConfig) {
this.mergeSettingsGenerationConfig(settingsGenerationConfig);
}
}
/**
@@ -642,88 +587,50 @@ export class ModelsConfig {
}
/**
* Sync state after auth refresh with fallback strategy:
* 1. If modelId can be found in modelRegistry, use the config from modelRegistry.
* 2. Otherwise, if existing credentials exist in resolved generationConfig from other sources
* (not modelProviders), preserve them and update authType/modelId only.
* 3. Otherwise, fall back to default model for the authType.
* 4. If no default is available, leave the generationConfig incomplete and let
* resolveContentGeneratorConfigWithSources throw exceptions as expected.
* Called by Config.refreshAuth to sync state after auth refresh.
*
* IMPORTANT: If credentials were manually set via updateCredentials(),
* we should NOT override them with modelProvider defaults.
* This handles the case where user inputs credentials via OpenAIKeyPrompt
* after removing environment variables for a previously selected model.
*/
syncAfterAuthRefresh(authType: AuthType, modelId?: string): void {
this.strictModelProviderSelection = false;
const previousAuthType = this.currentAuthType;
this.currentAuthType = authType;
// Check if we have manually set credentials that should be preserved
const preserveManualCredentials = this.hasManualCredentials;
// If credentials were manually set, don't apply modelProvider defaults
// Just update the authType and preserve the manually set credentials
if (preserveManualCredentials && authType === AuthType.USE_OPENAI) {
this.strictModelProviderSelection = false;
this.currentAuthType = authType;
if (modelId) {
this._generationConfig.model = modelId;
}
return;
}
this.strictModelProviderSelection = false;
// Step 1: If modelId exists in registry, always use config from modelRegistry
// Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it),
// so if modelId exists in registry, we should always use provider config.
// This handles provider switching even within the same authType.
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
const resolved = this.modelRegistry.getModel(authType, modelId);
if (resolved) {
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
// before applying defaults.
this.currentAuthType = authType;
this.applyResolvedModelDefaults(resolved);
this.strictModelProviderSelection = true;
return;
}
}
// Step 2: Check if there are existing credentials from other sources (not modelProviders)
const apiKeySource = this.generationConfigSources['apiKey'];
const baseUrlSource = this.generationConfigSources['baseUrl'];
const hasExistingCredentials =
(this._generationConfig.apiKey &&
apiKeySource?.kind !== 'modelProviders') ||
(this._generationConfig.baseUrl &&
baseUrlSource?.kind !== 'modelProviders');
// Only preserve credentials if:
// 1. AuthType hasn't changed (credentials are authType-specific), AND
// 2. The modelId doesn't exist in the registry (if it did, we would have used provider config in Step 1), AND
// 3. Either:
// a. We have manual credentials (set via updateCredentials), OR
// b. We have existing credentials
// Note: Even if authType hasn't changed, switching to a different provider model (that exists in registry)
// will use provider config (Step 1), not preserve old credentials. This ensures credentials change when
// switching providers, independent of authType changes.
const isAuthTypeChange = previousAuthType !== authType;
const shouldPreserveCredentials =
!isAuthTypeChange &&
(modelId === undefined ||
!this.modelRegistry.hasModel(authType, modelId)) &&
(this.hasManualCredentials || hasExistingCredentials);
if (shouldPreserveCredentials) {
// Preserve existing credentials, just update authType and modelId if provided
if (modelId) {
this._generationConfig.model = modelId;
if (!this.generationConfigSources['model']) {
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: 'auth refresh (preserved credentials)',
};
}
}
return;
}
// Step 3: Fall back to default model for the authType
const defaultModel =
this.modelRegistry.getDefaultModelForAuthType(authType);
if (defaultModel) {
this.applyResolvedModelDefaults(defaultModel);
return;
}
// Step 4: No default available - leave generationConfig incomplete
// resolveContentGeneratorConfigWithSources will throw exceptions as expected
if (modelId) {
this._generationConfig.model = modelId;
if (!this.generationConfigSources['model']) {
this.generationConfigSources['model'] = {
kind: 'programmatic',
detail: 'auth refresh (no default model)',
};
} else {
// If the provided modelId doesn't exist in the registry for the new authType,
// use the default model for that authType instead of keeping the old model.
// This handles the case where switching from one authType (e.g., OPENAI with
// env vars) to another (e.g., qwen-oauth) - we should use the default model
// for the new authType, not the old model.
this.currentAuthType = authType;
const defaultModel =
this.modelRegistry.getDefaultModelForAuthType(authType);
if (defaultModel) {
this.applyResolvedModelDefaults(defaultModel);
}
}
}

View File

@@ -751,7 +751,6 @@ describe('getQwenOAuthClient', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -840,7 +839,9 @@ describe('getQwenOAuthClient', () => {
requireCachedCredentials: true,
}),
),
).rejects.toThrow('Please use /auth to re-authenticate.');
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
@@ -1006,7 +1007,6 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -1202,7 +1202,6 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -1406,7 +1405,6 @@ describe('Browser Launch and Error Handling', () => {
beforeEach(() => {
mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
originalFetch = global.fetch;
@@ -2045,7 +2043,6 @@ describe('SharedTokenManager Integration in QwenOAuth2Client', () => {
it('should handle TokenManagerError types correctly in getQwenOAuthClient', async () => {
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Test different TokenManagerError types

View File

@@ -516,7 +516,9 @@ export async function getQwenOAuthClient(
}
if (options?.requireCachedCredentials) {
throw new Error('Please use /auth to re-authenticate.');
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
@@ -738,9 +740,11 @@ async function authWithQwenDeviceFlow(
// Emit device authorization event for UI integration immediately
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
showFallbackMessage(deviceAuth.verification_uri_complete);
}
// Always show the fallback message in non-interactive environments to ensure
// users can see the authorization URL even if browser launching is attempted.
// This is critical for headless/remote environments where browser launching
// may silently fail without throwing an error.
showFallbackMessage(deviceAuth.verification_uri_complete);
// Try to open browser if not suppressed
if (!config.isBrowserLaunchSuppressed()) {

View File

@@ -112,62 +112,6 @@ You are a helpful assistant with this skill.
expect(config.filePath).toBe(validSkillConfig.filePath);
});
it('should parse markdown with CRLF line endings', () => {
const markdownCrlf = `---\r
name: test-skill\r
description: A test skill\r
---\r
\r
You are a helpful assistant with this skill.\r
`;
const config = manager.parseSkillContent(
markdownCrlf,
validSkillConfig.filePath,
'project',
);
expect(config.name).toBe('test-skill');
expect(config.description).toBe('A test skill');
expect(config.body).toBe('You are a helpful assistant with this skill.');
});
it('should parse markdown with UTF-8 BOM', () => {
const markdownWithBom = `\uFEFF---
name: test-skill
description: A test skill
---
You are a helpful assistant with this skill.
`;
const config = manager.parseSkillContent(
markdownWithBom,
validSkillConfig.filePath,
'project',
);
expect(config.name).toBe('test-skill');
expect(config.description).toBe('A test skill');
});
it('should parse markdown when body is empty and file ends after frontmatter', () => {
const frontmatterOnly = `---
name: test-skill
description: A test skill
---`;
const config = manager.parseSkillContent(
frontmatterOnly,
validSkillConfig.filePath,
'project',
);
expect(config.name).toBe('test-skill');
expect(config.description).toBe('A test skill');
expect(config.body).toBe('');
});
it('should parse content with allowedTools', () => {
const markdownWithTools = `---
name: test-skill

View File

@@ -307,11 +307,9 @@ export class SkillManager {
level: SkillLevel,
): SkillConfig {
try {
const normalizedContent = normalizeSkillFileContent(content);
// Split frontmatter and content
const frontmatterRegex = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/;
const match = normalizedContent.match(frontmatterRegex);
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
const match = content.match(frontmatterRegex);
if (!match) {
throw new Error('Invalid format: missing YAML frontmatter');
@@ -558,13 +556,3 @@ export class SkillManager {
}
}
}
function normalizeSkillFileContent(content: string): string {
// Strip UTF-8 BOM to ensure frontmatter starts at the first character.
let normalized = content.replace(/^\uFEFF/, '');
// Normalize line endings so skills authored on Windows (CRLF) parse correctly.
normalized = normalized.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return normalized;
}

View File

@@ -35,7 +35,6 @@ export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
export const EVENT_AUTH = 'qwen-code.auth';
export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback';
// Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -45,7 +45,6 @@ export {
logNextSpeakerCheck,
logAuth,
logSkillLaunch,
logUserFeedback,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
@@ -66,8 +65,6 @@ export {
NextSpeakerCheckEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
UserFeedbackRating,
} from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js';

View File

@@ -38,7 +38,6 @@ import {
EVENT_INVALID_CHUNK,
EVENT_AUTH,
EVENT_SKILL_LAUNCH,
EVENT_USER_FEEDBACK,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -87,7 +86,6 @@ import type {
InvalidChunkEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -889,32 +887,3 @@ export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
};
logger.emit(logRecord);
}
export function logUserFeedback(
config: Config,
event: UserFeedbackEvent,
): void {
const uiEvent = {
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
} as UiEvent;
uiTelemetryService.addEvent(uiEvent);
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
QwenLogger.getInstance(config)?.logUserFeedbackEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_USER_FEEDBACK,
'event.timestamp': new Date().toISOString(),
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `User feedback: Rating ${event.rating} for session ${event.session_id}.`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -39,7 +39,6 @@ import type {
ExtensionDisableEvent,
AuthEvent,
SkillLaunchEvent,
UserFeedbackEvent,
RipgrepFallbackEvent,
EndSessionEvent,
} from '../types.js';
@@ -843,21 +842,6 @@ export class QwenLogger {
this.flushIfNeeded();
}
logUserFeedbackEvent(event: UserFeedbackEvent): void {
const rumEvent = this.createActionEvent('user', 'user_feedback', {
properties: {
session_id: event.session_id,
rating: event.rating,
model: event.model,
approval_mode: event.approval_mode,
prompt_id: event.prompt_id || '',
},
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logChatCompressionEvent(event: ChatCompressionEvent): void {
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
properties: {

View File

@@ -757,38 +757,6 @@ export class SkillLaunchEvent implements BaseTelemetryEvent {
}
}
export enum UserFeedbackRating {
BAD = 1,
FINE = 2,
GOOD = 3,
}
export class UserFeedbackEvent implements BaseTelemetryEvent {
'event.name': 'user_feedback';
'event.timestamp': string;
session_id: string;
rating: UserFeedbackRating;
model: string;
approval_mode: string;
prompt_id?: string;
constructor(
session_id: string,
rating: UserFeedbackRating,
model: string,
approval_mode: string,
prompt_id?: string,
) {
this['event.name'] = 'user_feedback';
this['event.timestamp'] = new Date().toISOString();
this.session_id = session_id;
this.rating = rating;
this.model = model;
this.approval_mode = approval_mode;
this.prompt_id = prompt_id;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -818,8 +786,7 @@ export type TelemetryEvent =
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent
| AuthEvent
| SkillLaunchEvent
| UserFeedbackEvent;
| SkillLaunchEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';

View File

@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type { LspClient, LspLocation, LspReference } from '../lsp/types.js';
export interface LspFindReferencesParams {
/**
* Symbol name to resolve if a file/position is not provided.
*/
symbol?: string;
/**
* File path (absolute or workspace-relative).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
file?: string;
/**
* File URI (e.g., file:///path/to/file).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
uri?: string;
/**
* 1-based line number when targeting a specific file location.
*/
line?: number;
/**
* 1-based character/column number when targeting a specific file location.
*/
character?: number;
/**
* Whether to include the declaration in results (default: false).
*/
includeDeclaration?: boolean;
/**
* Optional server name override.
*/
serverName?: string;
/**
* Optional maximum number of results.
*/
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
serverName?: string;
fromSymbol: boolean;
}
| { error: string };
class LspFindReferencesInvocation extends BaseToolInvocation<
LspFindReferencesParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspFindReferencesParams,
) {
super(params);
}
getDescription(): string {
if (this.params.symbol) {
return `LSP find-references查引用 for symbol "${this.params.symbol}"`;
}
if (this.params.file && this.params.line !== undefined) {
return `LSP find-references查引用 at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.uri && this.params.line !== undefined) {
return `LSP find-references查引用 at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`;
}
return 'LSP find-references查引用';
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP find-references is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const target = await this.resolveTarget(client);
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 50;
let references: LspReference[] = [];
try {
references = await client.references(
target.location,
target.serverName,
this.params.includeDeclaration ?? false,
limit,
);
} catch (error) {
const message = `LSP find-references failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!references.length) {
const message = `No references found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = references
.slice(0, limit)
.map(
(reference, index) =>
`${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`,
);
const heading = `References for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async resolveTarget(
client: Pick<LspClient, 'workspaceSymbols'>,
): Promise<ResolvedTarget> {
const workspaceRoot = this.config.getProjectRoot();
const lineProvided = typeof this.params.line === 'number';
const character = this.params.character ?? 1;
if ((this.params.file || this.params.uri) && lineProvided) {
const uri = this.resolveUri(workspaceRoot);
if (!uri) {
return {
error:
'A valid file path or URI is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)),
character: Math.max(0, Math.floor(character - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocation(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
serverName: this.params.serverName,
fromSymbol: false,
};
}
if (this.params.symbol) {
try {
const symbols = await client.workspaceSymbols(this.params.symbol, 5);
if (!symbols.length) {
return {
error: `No symbols found for query "${this.params.symbol}".`,
};
}
const top = symbols[0];
return {
location: top.location,
description: `symbol "${this.params.symbol}"`,
serverName: this.params.serverName ?? top.serverName,
fromSymbol: true,
};
} catch (error) {
return {
error: `Workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`,
};
}
}
return {
error:
'Provide a symbol name or a file plus line (and optional character) to use find-references.',
};
}
private resolveUri(workspaceRoot: string): string | null {
if (this.params.uri) {
if (
this.params.uri.startsWith('file://') ||
this.params.uri.includes('://')
) {
return this.params.uri;
}
const absoluteUriPath = path.isAbsolute(this.params.uri)
? this.params.uri
: path.resolve(workspaceRoot, this.params.uri);
return pathToFileURL(absoluteUriPath).toString();
}
if (this.params.file) {
const absolutePath = path.isAbsolute(this.params.file)
? this.params.file
: path.resolve(workspaceRoot, this.params.file);
return pathToFileURL(absolutePath).toString();
}
return null;
}
private formatLocation(
location: LspReference | (LspLocation & { serverName?: string }),
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
}
export class LspFindReferencesTool extends BaseDeclarativeTool<
LspFindReferencesParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_FIND_REFERENCES;
constructor(private readonly config: Config) {
super(
LspFindReferencesTool.Name,
ToolDisplayNames.LSP_FIND_REFERENCES,
'Use LSP find-references for a symbol or a specific file location查引用优先于 grep 搜索)。',
Kind.Other,
{
type: 'object',
properties: {
symbol: {
type: 'string',
description:
'Symbol name to resolve when a file/position is not provided.',
},
file: {
type: 'string',
description:
'File path (absolute or workspace-relative). Requires `line`.',
},
uri: {
type: 'string',
description:
'File URI (file:///...). Requires `line` when provided.',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
includeDeclaration: {
type: 'boolean',
description:
'Include the declaration itself when looking up references.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
},
false,
false,
);
}
protected createInvocation(
params: LspFindReferencesParams,
): ToolInvocation<LspFindReferencesParams, ToolResult> {
return new LspFindReferencesInvocation(this.config, params);
}
}

View File

@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type { LspClient, LspDefinition, LspLocation } from '../lsp/types.js';
export interface LspGoToDefinitionParams {
/**
* Symbol name to resolve if a file/position is not provided.
*/
symbol?: string;
/**
* File path (absolute or workspace-relative).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
file?: string;
/**
* File URI (e.g., file:///path/to/file).
* Use together with `line` (1-based) and optional `character` (1-based).
*/
uri?: string;
/**
* 1-based line number when targeting a specific file location.
*/
line?: number;
/**
* 1-based character/column number when targeting a specific file location.
*/
character?: number;
/**
* Optional server name override.
*/
serverName?: string;
/**
* Optional maximum number of results.
*/
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
serverName?: string;
fromSymbol: boolean;
}
| { error: string };
class LspGoToDefinitionInvocation extends BaseToolInvocation<
LspGoToDefinitionParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspGoToDefinitionParams,
) {
super(params);
}
getDescription(): string {
if (this.params.symbol) {
return `LSP go-to-definition跳转定义 for symbol "${this.params.symbol}"`;
}
if (this.params.file && this.params.line !== undefined) {
return `LSP go-to-definition跳转定义 at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.uri && this.params.line !== undefined) {
return `LSP go-to-definition跳转定义 at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`;
}
return 'LSP go-to-definition跳转定义';
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP go-to-definition is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const target = await this.resolveTarget(client);
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let definitions: LspDefinition[] = [];
try {
definitions = await client.definitions(
target.location,
target.serverName,
limit,
);
} catch (error) {
const message = `LSP go-to-definition failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
// Fallback to the resolved symbol location if the server does not return definitions.
if (!definitions.length && target.fromSymbol) {
definitions = [
{
...target.location,
serverName: target.serverName,
},
];
}
if (!definitions.length) {
const message = `No definitions found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = definitions
.slice(0, limit)
.map(
(definition, index) =>
`${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`,
);
const heading = `Definitions for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async resolveTarget(
client: Pick<LspClient, 'workspaceSymbols'>,
): Promise<ResolvedTarget> {
const workspaceRoot = this.config.getProjectRoot();
const lineProvided = typeof this.params.line === 'number';
const character = this.params.character ?? 1;
if ((this.params.file || this.params.uri) && lineProvided) {
const uri = this.resolveUri(workspaceRoot);
if (!uri) {
return {
error:
'A valid file path or URI is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)),
character: Math.max(0, Math.floor(character - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocation(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
serverName: this.params.serverName,
fromSymbol: false,
};
}
if (this.params.symbol) {
try {
const symbols = await client.workspaceSymbols(this.params.symbol, 5);
if (!symbols.length) {
return {
error: `No symbols found for query "${this.params.symbol}".`,
};
}
const top = symbols[0];
return {
location: top.location,
description: `symbol "${this.params.symbol}"`,
serverName: this.params.serverName ?? top.serverName,
fromSymbol: true,
};
} catch (error) {
return {
error: `Workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`,
};
}
}
return {
error:
'Provide a symbol name or a file plus line (and optional character) to use go-to-definition.',
};
}
private resolveUri(workspaceRoot: string): string | null {
if (this.params.uri) {
if (
this.params.uri.startsWith('file://') ||
this.params.uri.includes('://')
) {
return this.params.uri;
}
const absoluteUriPath = path.isAbsolute(this.params.uri)
? this.params.uri
: path.resolve(workspaceRoot, this.params.uri);
return pathToFileURL(absoluteUriPath).toString();
}
if (this.params.file) {
const absolutePath = path.isAbsolute(this.params.file)
? this.params.file
: path.resolve(workspaceRoot, this.params.file);
return pathToFileURL(absolutePath).toString();
}
return null;
}
private formatLocation(
location: LspDefinition | (LspLocation & { serverName?: string }),
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
}
export class LspGoToDefinitionTool extends BaseDeclarativeTool<
LspGoToDefinitionParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_GO_TO_DEFINITION;
constructor(private readonly config: Config) {
super(
LspGoToDefinitionTool.Name,
ToolDisplayNames.LSP_GO_TO_DEFINITION,
'Use LSP go-to-definition for a symbol or a specific file location跳转定义优先于 grep 搜索)。',
Kind.Other,
{
type: 'object',
properties: {
symbol: {
type: 'string',
description:
'Symbol name to resolve when a file/position is not provided.',
},
file: {
type: 'string',
description:
'File path (absolute or workspace-relative). Requires `line`.',
},
uri: {
type: 'string',
description:
'File URI (file:///...). Requires `line` when provided.',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
},
false,
false,
);
}
protected createInvocation(
params: LspGoToDefinitionParams,
): ToolInvocation<LspGoToDefinitionParams, ToolResult> {
return new LspGoToDefinitionInvocation(this.config, params);
}
}

View File

@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type { LspSymbolInformation } from '../lsp/types.js';
export interface LspWorkspaceSymbolParams {
/**
* Query string to search symbols (e.g., function or class name).
*/
query: string;
/**
* Maximum number of results to return.
*/
limit?: number;
}
class LspWorkspaceSymbolInvocation extends BaseToolInvocation<
LspWorkspaceSymbolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: LspWorkspaceSymbolParams,
) {
super(params);
}
getDescription(): string {
return `LSP workspace symbol search按名称找定义/实现/引用) for "${this.params.query}"`;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message =
'LSP workspace symbol search is unavailable (LSP disabled or not initialized).';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 20;
let symbols: LspSymbolInformation[] = [];
try {
symbols = await client.workspaceSymbols(this.params.query, limit);
} catch (error) {
const message = `LSP workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!symbols.length) {
const message = `No symbols found for query "${this.params.query}".`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = symbols.slice(0, limit).map((symbol, index) => {
const location = this.formatLocation(symbol, workspaceRoot);
const serverSuffix = symbol.serverName
? ` [${symbol.serverName}]`
: '';
const kind = symbol.kind ? ` (${symbol.kind})` : '';
const container = symbol.containerName
? ` in ${symbol.containerName}`
: '';
return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;
});
const heading = `Found ${Math.min(symbols.length, limit)} of ${
symbols.length
} symbols for query "${this.params.query}":`;
let referenceSection = '';
const topSymbol = symbols[0];
if (topSymbol) {
try {
const referenceLimit = Math.min(20, Math.max(limit, 5));
const references = await client.references(
topSymbol.location,
topSymbol.serverName,
false,
referenceLimit,
);
if (references.length > 0) {
const refLines = references.map((ref, index) => {
const location = this.formatLocation(
{ location: ref, name: '', kind: undefined },
workspaceRoot,
);
const serverSuffix = ref.serverName
? ` [${ref.serverName}]`
: '';
return `${index + 1}. ${location}${serverSuffix}`;
});
referenceSection = [
'',
`References for top match (${topSymbol.name}):`,
...refLines,
].join('\n');
}
} catch (error) {
referenceSection = `\nReferences lookup failed: ${
(error as Error)?.message || String(error)
}`;
}
}
const llmParts = referenceSection
? [heading, ...lines, referenceSection]
: [heading, ...lines];
const displayParts = referenceSection
? [...lines, referenceSection]
: [...lines];
return {
llmContent: llmParts.join('\n'),
returnDisplay: displayParts.join('\n'),
};
}
private formatLocation(symbol: LspSymbolInformation, workspaceRoot: string) {
const { uri, range } = symbol.location;
let filePath = uri;
if (uri.startsWith('file://')) {
filePath = fileURLToPath(uri);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const line = (range.start.line ?? 0) + 1;
const character = (range.start.character ?? 0) + 1;
return `${filePath}:${line}:${character}`;
}
}
export class LspWorkspaceSymbolTool extends BaseDeclarativeTool<
LspWorkspaceSymbolParams,
ToolResult
> {
static readonly Name = ToolNames.LSP_WORKSPACE_SYMBOL;
constructor(private readonly config: Config) {
super(
LspWorkspaceSymbolTool.Name,
ToolDisplayNames.LSP_WORKSPACE_SYMBOL,
'Search workspace symbols via LSP查找定义/实现/引用,按名称定位符号,优先于 grep。',
Kind.Other,
{
type: 'object',
properties: {
query: {
type: 'string',
description:
'Symbol name query, e.g., function/class/variable name to search.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
required: ['query'],
},
false,
false,
);
}
protected createInvocation(
params: LspWorkspaceSymbolParams,
): ToolInvocation<LspWorkspaceSymbolParams, ToolResult> {
return new LspWorkspaceSymbolInvocation(this.config, params);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,960 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import type {
LspCallHierarchyIncomingCall,
LspCallHierarchyItem,
LspCallHierarchyOutgoingCall,
LspClient,
LspDefinition,
LspLocation,
LspRange,
LspReference,
LspSymbolInformation,
} from '../lsp/types.js';
/**
* Supported LSP operations.
*/
export type LspOperation =
| 'goToDefinition'
| 'findReferences'
| 'hover'
| 'documentSymbol'
| 'workspaceSymbol'
| 'goToImplementation'
| 'prepareCallHierarchy'
| 'incomingCalls'
| 'outgoingCalls';
/**
* Parameters for the unified LSP tool.
*/
export interface LspToolParams {
/** Operation to perform. */
operation: LspOperation;
/** File path (absolute or workspace-relative). */
filePath?: string;
/** 1-based line number when targeting a specific file location. */
line?: number;
/** 1-based character/column number when targeting a specific file location. */
character?: number;
/** Whether to include the declaration in reference results. */
includeDeclaration?: boolean;
/** Query string for workspace symbol search. */
query?: string;
/** Call hierarchy item from a previous call hierarchy operation. */
callHierarchyItem?: LspCallHierarchyItem;
/** Optional server name override. */
serverName?: string;
/** Optional maximum number of results. */
limit?: number;
}
type ResolvedTarget =
| {
location: LspLocation;
description: string;
}
| { error: string };
/** Operations that require filePath and line. */
const LOCATION_REQUIRED_OPERATIONS = new Set<LspOperation>([
'goToDefinition',
'findReferences',
'hover',
'goToImplementation',
'prepareCallHierarchy',
]);
/** Operations that only require filePath. */
const FILE_REQUIRED_OPERATIONS = new Set<LspOperation>(['documentSymbol']);
/** Operations that require query. */
const QUERY_REQUIRED_OPERATIONS = new Set<LspOperation>(['workspaceSymbol']);
/** Operations that require callHierarchyItem. */
const ITEM_REQUIRED_OPERATIONS = new Set<LspOperation>([
'incomingCalls',
'outgoingCalls',
]);
class LspToolInvocation extends BaseToolInvocation<LspToolParams, ToolResult> {
constructor(
private readonly config: Config,
params: LspToolParams,
) {
super(params);
}
getDescription(): string {
const operationLabel = this.getOperationLabel();
if (this.params.operation === 'workspaceSymbol') {
return `LSP ${operationLabel} for "${this.params.query ?? ''}"`;
}
if (this.params.operation === 'documentSymbol') {
return this.params.filePath
? `LSP ${operationLabel} for ${this.params.filePath}`
: `LSP ${operationLabel}`;
}
if (
this.params.operation === 'incomingCalls' ||
this.params.operation === 'outgoingCalls'
) {
return `LSP ${operationLabel} for ${this.describeCallHierarchyItemShort()}`;
}
if (this.params.filePath && this.params.line !== undefined) {
return `LSP ${operationLabel} at ${this.params.filePath}:${this.params.line}:${this.params.character ?? 1}`;
}
if (this.params.filePath) {
return `LSP ${operationLabel} for ${this.params.filePath}`;
}
return `LSP ${operationLabel}`;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const client = this.config.getLspClient();
if (!client || !this.config.isLspEnabled()) {
const message = `LSP ${this.getOperationLabel()} is unavailable (LSP disabled or not initialized).`;
return { llmContent: message, returnDisplay: message };
}
switch (this.params.operation) {
case 'goToDefinition':
return this.executeDefinitions(client);
case 'findReferences':
return this.executeReferences(client);
case 'hover':
return this.executeHover(client);
case 'documentSymbol':
return this.executeDocumentSymbols(client);
case 'workspaceSymbol':
return this.executeWorkspaceSymbols(client);
case 'goToImplementation':
return this.executeImplementations(client);
case 'prepareCallHierarchy':
return this.executePrepareCallHierarchy(client);
case 'incomingCalls':
return this.executeIncomingCalls(client);
case 'outgoingCalls':
return this.executeOutgoingCalls(client);
default: {
const message = `Unsupported LSP operation: ${this.params.operation}`;
return { llmContent: message, returnDisplay: message };
}
}
}
private async executeDefinitions(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let definitions: LspDefinition[] = [];
try {
definitions = await client.definitions(
target.location,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP go-to-definition failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!definitions.length) {
const message = `No definitions found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = definitions
.slice(0, limit)
.map(
(definition, index) =>
`${index + 1}. ${this.formatLocationWithServer(definition, workspaceRoot)}`,
);
const heading = `Definitions for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeImplementations(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let implementations: LspDefinition[] = [];
try {
implementations = await client.implementations(
target.location,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP go-to-implementation failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!implementations.length) {
const message = `No implementations found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = implementations
.slice(0, limit)
.map(
(implementation, index) =>
`${index + 1}. ${this.formatLocationWithServer(implementation, workspaceRoot)}`,
);
const heading = `Implementations for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeReferences(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 50;
let references: LspReference[] = [];
try {
references = await client.references(
target.location,
this.params.serverName,
this.params.includeDeclaration ?? false,
limit,
);
} catch (error) {
const message = `LSP find-references failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!references.length) {
const message = `No references found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = references
.slice(0, limit)
.map(
(reference, index) =>
`${index + 1}. ${this.formatLocationWithServer(reference, workspaceRoot)}`,
);
const heading = `References for ${target.description}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeHover(client: LspClient): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
let hoverText = '';
try {
const result = await client.hover(
target.location,
this.params.serverName,
);
if (result) {
hoverText = result.contents ?? '';
}
} catch (error) {
const message = `LSP hover failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!hoverText || hoverText.trim().length === 0) {
const message = `No hover information found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const heading = `Hover for ${target.description}:`;
const content = hoverText.trim();
return {
llmContent: `${heading}\n${content}`,
returnDisplay: content,
};
}
private async executeDocumentSymbols(client: LspClient): Promise<ToolResult> {
const workspaceRoot = this.config.getProjectRoot();
const filePath = this.params.filePath ?? '';
const uri = this.resolveUri(filePath, workspaceRoot);
if (!uri) {
const message = 'A valid filePath is required for document symbols.';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 50;
let symbols: LspSymbolInformation[] = [];
try {
symbols = await client.documentSymbols(
uri,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP document symbols failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!symbols.length) {
const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);
const message = `No document symbols found for ${fileLabel}.`;
return { llmContent: message, returnDisplay: message };
}
const lines = symbols.slice(0, limit).map((symbol, index) => {
const location = this.formatLocationWithoutServer(
symbol.location,
workspaceRoot,
);
const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : '';
const kind = symbol.kind ? ` (${symbol.kind})` : '';
const container = symbol.containerName
? ` in ${symbol.containerName}`
: '';
return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;
});
const fileLabel = this.formatUriForDisplay(uri, workspaceRoot);
const heading = `Document symbols for ${fileLabel}:`;
return {
llmContent: [heading, ...lines].join('\n'),
returnDisplay: lines.join('\n'),
};
}
private async executeWorkspaceSymbols(
client: LspClient,
): Promise<ToolResult> {
const limit = this.params.limit ?? 20;
const query = this.params.query ?? '';
let symbols: LspSymbolInformation[] = [];
try {
symbols = await client.workspaceSymbols(query, limit);
} catch (error) {
const message = `LSP workspace symbol search failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!symbols.length) {
const message = `No symbols found for query "${query}".`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const lines = symbols.slice(0, limit).map((symbol, index) => {
const location = this.formatLocationWithoutServer(
symbol.location,
workspaceRoot,
);
const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : '';
const kind = symbol.kind ? ` (${symbol.kind})` : '';
const container = symbol.containerName
? ` in ${symbol.containerName}`
: '';
return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`;
});
const heading = `Found ${Math.min(symbols.length, limit)} of ${
symbols.length
} symbols for query "${query}":`;
// Also fetch references for the top match to provide additional context.
let referenceSection = '';
const topSymbol = symbols[0];
if (topSymbol) {
try {
const referenceLimit = Math.min(20, Math.max(limit, 5));
const references = await client.references(
topSymbol.location,
topSymbol.serverName,
false,
referenceLimit,
);
if (references.length > 0) {
const refLines = references.map((ref, index) => {
const location = this.formatLocationWithoutServer(
ref,
workspaceRoot,
);
const serverSuffix = ref.serverName ? ` [${ref.serverName}]` : '';
return `${index + 1}. ${location}${serverSuffix}`;
});
referenceSection = [
'',
`References for top match (${topSymbol.name}):`,
...refLines,
].join('\n');
}
} catch (error) {
referenceSection = `\nReferences lookup failed: ${
(error as Error)?.message || String(error)
}`;
}
}
const llmParts = referenceSection
? [heading, ...lines, referenceSection]
: [heading, ...lines];
const displayParts = referenceSection
? [...lines, referenceSection]
: [...lines];
return {
llmContent: llmParts.join('\n'),
returnDisplay: displayParts.join('\n'),
};
}
private async executePrepareCallHierarchy(
client: LspClient,
): Promise<ToolResult> {
const target = this.resolveLocationTarget();
if ('error' in target) {
return { llmContent: target.error, returnDisplay: target.error };
}
const limit = this.params.limit ?? 20;
let items: LspCallHierarchyItem[] = [];
try {
items = await client.prepareCallHierarchy(
target.location,
this.params.serverName,
limit,
);
} catch (error) {
const message = `LSP call hierarchy prepare failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!items.length) {
const message = `No call hierarchy items found for ${target.description}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const slicedItems = items.slice(0, limit);
const lines = slicedItems.map((item, index) =>
this.formatCallHierarchyItemLine(item, index, workspaceRoot),
);
const heading = `Call hierarchy items for ${target.description}:`;
const jsonSection = this.formatJsonSection(
'Call hierarchy items (JSON)',
slicedItems,
);
return {
llmContent: [heading, ...lines].join('\n') + jsonSection,
returnDisplay: lines.join('\n'),
};
}
private async executeIncomingCalls(client: LspClient): Promise<ToolResult> {
const item = this.params.callHierarchyItem;
if (!item) {
const message = 'callHierarchyItem is required for incomingCalls.';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 20;
const serverName = this.params.serverName ?? item.serverName;
let calls: LspCallHierarchyIncomingCall[] = [];
try {
calls = await client.incomingCalls(item, serverName, limit);
} catch (error) {
const message = `LSP incoming calls failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!calls.length) {
const message = `No incoming calls found for ${this.describeCallHierarchyItemFull(
item,
)}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const slicedCalls = calls.slice(0, limit);
const lines = slicedCalls.map((call, index) => {
const targetItem = call.from;
const location = this.formatLocationWithServer(
{
uri: targetItem.uri,
range: targetItem.selectionRange,
serverName: targetItem.serverName,
},
workspaceRoot,
);
const kind = targetItem.kind ? ` (${targetItem.kind})` : '';
const detail = targetItem.detail ? ` ${targetItem.detail}` : '';
const rangeSuffix = this.formatCallRanges(call.fromRanges);
return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;
});
const heading = `Incoming calls for ${this.describeCallHierarchyItemFull(
item,
)}:`;
const jsonSection = this.formatJsonSection(
'Incoming calls (JSON)',
slicedCalls,
);
return {
llmContent: [heading, ...lines].join('\n') + jsonSection,
returnDisplay: lines.join('\n'),
};
}
private async executeOutgoingCalls(client: LspClient): Promise<ToolResult> {
const item = this.params.callHierarchyItem;
if (!item) {
const message = 'callHierarchyItem is required for outgoingCalls.';
return { llmContent: message, returnDisplay: message };
}
const limit = this.params.limit ?? 20;
const serverName = this.params.serverName ?? item.serverName;
let calls: LspCallHierarchyOutgoingCall[] = [];
try {
calls = await client.outgoingCalls(item, serverName, limit);
} catch (error) {
const message = `LSP outgoing calls failed: ${
(error as Error)?.message || String(error)
}`;
return { llmContent: message, returnDisplay: message };
}
if (!calls.length) {
const message = `No outgoing calls found for ${this.describeCallHierarchyItemFull(
item,
)}.`;
return { llmContent: message, returnDisplay: message };
}
const workspaceRoot = this.config.getProjectRoot();
const slicedCalls = calls.slice(0, limit);
const lines = slicedCalls.map((call, index) => {
const targetItem = call.to;
const location = this.formatLocationWithServer(
{
uri: targetItem.uri,
range: targetItem.selectionRange,
serverName: targetItem.serverName,
},
workspaceRoot,
);
const kind = targetItem.kind ? ` (${targetItem.kind})` : '';
const detail = targetItem.detail ? ` ${targetItem.detail}` : '';
const rangeSuffix = this.formatCallRanges(call.fromRanges);
return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`;
});
const heading = `Outgoing calls for ${this.describeCallHierarchyItemFull(
item,
)}:`;
const jsonSection = this.formatJsonSection(
'Outgoing calls (JSON)',
slicedCalls,
);
return {
llmContent: [heading, ...lines].join('\n') + jsonSection,
returnDisplay: lines.join('\n'),
};
}
private resolveLocationTarget(): ResolvedTarget {
const filePath = this.params.filePath;
if (!filePath) {
return {
error: 'filePath is required for this operation.',
};
}
if (typeof this.params.line !== 'number') {
return {
error: 'line is required for this operation.',
};
}
const workspaceRoot = this.config.getProjectRoot();
const uri = this.resolveUri(filePath, workspaceRoot);
if (!uri) {
return {
error: 'A valid filePath is required when specifying a line/character.',
};
}
const position = {
line: Math.max(0, Math.floor(this.params.line - 1)),
character: Math.max(0, Math.floor((this.params.character ?? 1) - 1)),
};
const location: LspLocation = {
uri,
range: { start: position, end: position },
};
const description = this.formatLocationWithServer(
{ ...location, serverName: this.params.serverName },
workspaceRoot,
);
return {
location,
description,
};
}
private resolveUri(filePath: string, workspaceRoot: string): string | null {
if (!filePath) {
return null;
}
if (filePath.startsWith('file://') || filePath.includes('://')) {
return filePath;
}
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(workspaceRoot, filePath);
return pathToFileURL(absolutePath).toString();
}
private formatLocationWithServer(
location: LspLocation & { serverName?: string },
workspaceRoot: string,
): string {
const start = location.range.start;
let filePath = location.uri;
if (filePath.startsWith('file://')) {
filePath = fileURLToPath(filePath);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const serverSuffix =
location.serverName && location.serverName !== ''
? ` [${location.serverName}]`
: '';
return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`;
}
private formatLocationWithoutServer(
location: LspLocation,
workspaceRoot: string,
): string {
const { uri, range } = location;
let filePath = uri;
if (uri.startsWith('file://')) {
filePath = fileURLToPath(uri);
filePath = path.relative(workspaceRoot, filePath) || '.';
}
const line = (range.start.line ?? 0) + 1;
const character = (range.start.character ?? 0) + 1;
return `${filePath}:${line}:${character}`;
}
private formatCallHierarchyItemLine(
item: LspCallHierarchyItem,
index: number,
workspaceRoot: string,
): string {
const location = this.formatLocationWithServer(
{
uri: item.uri,
range: item.selectionRange,
serverName: item.serverName,
},
workspaceRoot,
);
const kind = item.kind ? ` (${item.kind})` : '';
const detail = item.detail ? ` ${item.detail}` : '';
return `${index + 1}. ${item.name}${kind}${detail} - ${location}`;
}
private formatCallRanges(ranges: LspRange[]): string {
if (!ranges.length) {
return '';
}
const formatted = ranges.map((range) => this.formatPosition(range.start));
const maxShown = 3;
const shown = formatted.slice(0, maxShown);
const extra =
formatted.length > maxShown
? `, +${formatted.length - maxShown} more`
: '';
return ` (calls at ${shown.join(', ')}${extra})`;
}
private formatPosition(position: LspRange['start']): string {
return `${(position.line ?? 0) + 1}:${(position.character ?? 0) + 1}`;
}
private formatUriForDisplay(uri: string, workspaceRoot: string): string {
let filePath = uri;
if (uri.startsWith('file://')) {
filePath = fileURLToPath(uri);
}
if (path.isAbsolute(filePath)) {
return path.relative(workspaceRoot, filePath) || '.';
}
return filePath;
}
private formatJsonSection(label: string, data: unknown): string {
return `\n\n${label}:\n${JSON.stringify(data, null, 2)}`;
}
private describeCallHierarchyItemShort(): string {
const item = this.params.callHierarchyItem;
if (!item) {
return 'call hierarchy item';
}
return item.name || 'call hierarchy item';
}
private describeCallHierarchyItemFull(item: LspCallHierarchyItem): string {
const workspaceRoot = this.config.getProjectRoot();
const location = this.formatLocationWithServer(
{
uri: item.uri,
range: item.selectionRange,
serverName: item.serverName,
},
workspaceRoot,
);
return `${item.name} at ${location}`;
}
private getOperationLabel(): string {
switch (this.params.operation) {
case 'goToDefinition':
return 'go-to-definition';
case 'findReferences':
return 'find-references';
case 'hover':
return 'hover';
case 'documentSymbol':
return 'document symbols';
case 'workspaceSymbol':
return 'workspace symbol search';
case 'goToImplementation':
return 'go-to-implementation';
case 'prepareCallHierarchy':
return 'prepare call hierarchy';
case 'incomingCalls':
return 'incoming calls';
case 'outgoingCalls':
return 'outgoing calls';
default:
return this.params.operation;
}
}
}
/**
* Unified LSP tool that supports multiple operations:
* - goToDefinition: Find where a symbol is defined
* - findReferences: Find all references to a symbol
* - hover: Get hover information (documentation, type info)
* - documentSymbol: Get all symbols in a document
* - workspaceSymbol: Search for symbols across the workspace
* - goToImplementation: Find implementations of an interface or abstract method
* - prepareCallHierarchy: Get call hierarchy item at a position
* - incomingCalls: Find all functions that call the given function
* - outgoingCalls: Find all functions called by the given function
*/
export class LspTool extends BaseDeclarativeTool<LspToolParams, ToolResult> {
static readonly Name = ToolNames.LSP;
constructor(private readonly config: Config) {
super(
LspTool.Name,
ToolDisplayNames.LSP,
'Unified LSP operations for definitions, references, hover, symbols, and call hierarchy.',
Kind.Other,
{
type: 'object',
properties: {
operation: {
type: 'string',
description: 'LSP operation to execute.',
enum: [
'goToDefinition',
'findReferences',
'hover',
'documentSymbol',
'workspaceSymbol',
'goToImplementation',
'prepareCallHierarchy',
'incomingCalls',
'outgoingCalls',
],
},
filePath: {
type: 'string',
description: 'File path (absolute or workspace-relative).',
},
line: {
type: 'number',
description: '1-based line number for the target location.',
},
character: {
type: 'number',
description:
'1-based character/column number for the target location.',
},
includeDeclaration: {
type: 'boolean',
description:
'Include the declaration itself when looking up references.',
},
query: {
type: 'string',
description: 'Symbol query for workspace symbol search.',
},
callHierarchyItem: {
$ref: '#/definitions/LspCallHierarchyItem',
description: 'Call hierarchy item for incoming/outgoing calls.',
},
serverName: {
type: 'string',
description: 'Optional LSP server name to target.',
},
limit: {
type: 'number',
description: 'Optional maximum number of results to return.',
},
},
required: ['operation'],
definitions: {
LspPosition: {
type: 'object',
properties: {
line: { type: 'number' },
character: { type: 'number' },
},
required: ['line', 'character'],
},
LspRange: {
type: 'object',
properties: {
start: { $ref: '#/definitions/LspPosition' },
end: { $ref: '#/definitions/LspPosition' },
},
required: ['start', 'end'],
},
LspCallHierarchyItem: {
type: 'object',
properties: {
name: { type: 'string' },
kind: { type: 'string' },
rawKind: { type: 'number' },
detail: { type: 'string' },
uri: { type: 'string' },
range: { $ref: '#/definitions/LspRange' },
selectionRange: { $ref: '#/definitions/LspRange' },
data: {},
serverName: { type: 'string' },
},
required: ['name', 'uri', 'range', 'selectionRange'],
},
},
},
false,
false,
);
}
protected override validateToolParamValues(
params: LspToolParams,
): string | null {
const operation = params.operation;
if (LOCATION_REQUIRED_OPERATIONS.has(operation)) {
if (!params.filePath || params.filePath.trim() === '') {
return `filePath is required for ${operation}.`;
}
if (typeof params.line !== 'number') {
return `line is required for ${operation}.`;
}
}
if (FILE_REQUIRED_OPERATIONS.has(operation)) {
if (!params.filePath || params.filePath.trim() === '') {
return `filePath is required for ${operation}.`;
}
}
if (QUERY_REQUIRED_OPERATIONS.has(operation)) {
if (!params.query || params.query.trim() === '') {
return `query is required for ${operation}.`;
}
}
if (ITEM_REQUIRED_OPERATIONS.has(operation)) {
if (!params.callHierarchyItem) {
return `callHierarchyItem is required for ${operation}.`;
}
}
if (params.line !== undefined && params.line < 1) {
return 'line must be a positive number.';
}
if (params.character !== undefined && params.character < 1) {
return 'character must be a positive number.';
}
if (params.limit !== undefined && params.limit <= 0) {
return 'limit must be a positive number.';
}
return null;
}
protected createInvocation(
params: LspToolParams,
): ToolInvocation<LspToolParams, ToolResult> {
return new LspToolInvocation(this.config, params);
}
}

View File

@@ -25,6 +25,11 @@ export const ToolNames = {
WEB_FETCH: 'web_fetch',
WEB_SEARCH: 'web_search',
LS: 'list_directory',
LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol',
LSP_GO_TO_DEFINITION: 'lsp_go_to_definition',
LSP_FIND_REFERENCES: 'lsp_find_references',
/** Unified LSP tool supporting all LSP operations. */
LSP: 'lsp',
} as const;
/**
@@ -48,6 +53,11 @@ export const ToolDisplayNames = {
WEB_FETCH: 'WebFetch',
WEB_SEARCH: 'WebSearch',
LS: 'ListFiles',
LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol',
LSP_GO_TO_DEFINITION: 'LspGoToDefinition',
LSP_FIND_REFERENCES: 'LspFindReferences',
/** Unified LSP tool display name. */
LSP: 'Lsp',
} as const;
// Migration from old tool names to new tool names
@@ -56,6 +66,8 @@ export const ToolDisplayNames = {
export const ToolNamesMigration = {
search_file_content: ToolNames.GREP, // Legacy name from grep tool
replace: ToolNames.EDIT, // Legacy name from edit tool
go_to_definition: ToolNames.LSP_GO_TO_DEFINITION,
find_references: ToolNames.LSP_FIND_REFERENCES,
} as const;
// Migration from old tool display names to new tool display names

View File

@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fsPromises from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { bfsFileSearch } from './bfsFileSearch.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
describe('bfsFileSearch', () => {
let testRootDir: string;
async function createEmptyDir(...pathSegments: string[]) {
const fullPath = path.join(testRootDir, ...pathSegments);
await fsPromises.mkdir(fullPath, { recursive: true });
return fullPath;
}
async function createTestFile(content: string, ...pathSegments: string[]) {
const fullPath = path.join(testRootDir, ...pathSegments);
await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
await fsPromises.writeFile(fullPath, content);
return fullPath;
}
beforeEach(async () => {
testRootDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'bfs-file-search-test-'),
);
});
afterEach(async () => {
await fsPromises.rm(testRootDir, { recursive: true, force: true });
});
it('should find a file in the root directory', async () => {
const targetFilePath = await createTestFile('content', 'target.txt');
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
expect(result).toEqual([targetFilePath]);
});
it('should find a file in a nested directory', async () => {
const targetFilePath = await createTestFile(
'content',
'a',
'b',
'target.txt',
);
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
expect(result).toEqual([targetFilePath]);
});
it('should find multiple files with the same name', async () => {
const targetFilePath1 = await createTestFile('content1', 'a', 'target.txt');
const targetFilePath2 = await createTestFile('content2', 'b', 'target.txt');
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
result.sort();
expect(result).toEqual([targetFilePath1, targetFilePath2].sort());
});
it('should return an empty array if no file is found', async () => {
await createTestFile('content', 'other.txt');
const result = await bfsFileSearch(testRootDir, { fileName: 'target.txt' });
expect(result).toEqual([]);
});
it('should ignore directories specified in ignoreDirs', async () => {
await createTestFile('content', 'ignored', 'target.txt');
const targetFilePath = await createTestFile(
'content',
'not-ignored',
'target.txt',
);
const result = await bfsFileSearch(testRootDir, {
fileName: 'target.txt',
ignoreDirs: ['ignored'],
});
expect(result).toEqual([targetFilePath]);
});
it('should respect the maxDirs limit and not find the file', async () => {
await createTestFile('content', 'a', 'b', 'c', 'target.txt');
const result = await bfsFileSearch(testRootDir, {
fileName: 'target.txt',
maxDirs: 3,
});
expect(result).toEqual([]);
});
it('should respect the maxDirs limit and find the file', async () => {
const targetFilePath = await createTestFile(
'content',
'a',
'b',
'c',
'target.txt',
);
const result = await bfsFileSearch(testRootDir, {
fileName: 'target.txt',
maxDirs: 4,
});
expect(result).toEqual([targetFilePath]);
});
describe('with FileDiscoveryService', () => {
let projectRoot: string;
beforeEach(async () => {
projectRoot = await createEmptyDir('project');
});
it('should ignore gitignored files', async () => {
await createEmptyDir('project', '.git');
await createTestFile('node_modules/', 'project', '.gitignore');
await createTestFile('content', 'project', 'node_modules', 'target.txt');
const targetFilePath = await createTestFile(
'content',
'project',
'not-ignored',
'target.txt',
);
const fileService = new FileDiscoveryService(projectRoot);
const result = await bfsFileSearch(projectRoot, {
fileName: 'target.txt',
fileService,
fileFilteringOptions: {
respectGitIgnore: true,
respectQwenIgnore: true,
},
});
expect(result).toEqual([targetFilePath]);
});
it('should ignore qwenignored files', async () => {
await createTestFile('node_modules/', 'project', '.qwenignore');
await createTestFile('content', 'project', 'node_modules', 'target.txt');
const targetFilePath = await createTestFile(
'content',
'project',
'not-ignored',
'target.txt',
);
const fileService = new FileDiscoveryService(projectRoot);
const result = await bfsFileSearch(projectRoot, {
fileName: 'target.txt',
fileService,
fileFilteringOptions: {
respectGitIgnore: false,
respectQwenIgnore: true,
},
});
expect(result).toEqual([targetFilePath]);
});
it('should not ignore files if respect flags are false', async () => {
await createEmptyDir('project', '.git');
await createTestFile('node_modules/', 'project', '.gitignore');
const target1 = await createTestFile(
'content',
'project',
'node_modules',
'target.txt',
);
const target2 = await createTestFile(
'content',
'project',
'not-ignored',
'target.txt',
);
const fileService = new FileDiscoveryService(projectRoot);
const result = await bfsFileSearch(projectRoot, {
fileName: 'target.txt',
fileService,
fileFilteringOptions: {
respectGitIgnore: false,
respectQwenIgnore: false,
},
});
expect(result.sort()).toEqual([target1, target2].sort());
});
});
it('should find all files in a complex directory structure', async () => {
// Create a complex directory structure to test correctness at scale
// without flaky performance checks.
const numDirs = 50;
const numFilesPerDir = 2;
const numTargetDirs = 10;
const dirCreationPromises: Array<Promise<unknown>> = [];
for (let i = 0; i < numDirs; i++) {
dirCreationPromises.push(createEmptyDir(`dir${i}`));
dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir1'));
dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir2'));
dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir1', 'deep'));
}
await Promise.all(dirCreationPromises);
const fileCreationPromises: Array<Promise<string>> = [];
for (let i = 0; i < numTargetDirs; i++) {
// Add target files in some directories
fileCreationPromises.push(
createTestFile('content', `dir${i}`, 'QWEN.md'),
);
fileCreationPromises.push(
createTestFile('content', `dir${i}`, 'subdir1', 'QWEN.md'),
);
}
const expectedFiles = await Promise.all(fileCreationPromises);
const result = await bfsFileSearch(testRootDir, {
fileName: 'QWEN.md',
// Provide a generous maxDirs limit to ensure it doesn't prematurely stop
// in this large test case. Total dirs created is 200.
maxDirs: 250,
});
// Verify we found the exact files we created
expect(result.length).toBe(numTargetDirs * numFilesPerDir);
expect(result.sort()).toEqual(expectedFiles.sort());
});
});

View File

@@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import type { FileFilteringOptions } from '../config/constants.js';
// Simple console logger for now.
// TODO: Integrate with a more robust server-side logger.
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
debug: (...args: any[]) => console.debug('[DEBUG] [BfsFileSearch]', ...args),
};
interface BfsFileSearchOptions {
fileName: string;
ignoreDirs?: string[];
maxDirs?: number;
debug?: boolean;
fileService?: FileDiscoveryService;
fileFilteringOptions?: FileFilteringOptions;
}
/**
* Performs a breadth-first search for a specific file within a directory structure.
*
* @param rootDir The directory to start the search from.
* @param options Configuration for the search.
* @returns A promise that resolves to an array of paths where the file was found.
*/
export async function bfsFileSearch(
rootDir: string,
options: BfsFileSearchOptions,
): Promise<string[]> {
const {
fileName,
ignoreDirs = [],
maxDirs = Infinity,
debug = false,
fileService,
} = options;
const foundFiles: string[] = [];
const queue: string[] = [rootDir];
const visited = new Set<string>();
let scannedDirCount = 0;
let queueHead = 0; // Pointer-based queue head to avoid expensive splice operations
// Convert ignoreDirs array to Set for O(1) lookup performance
const ignoreDirsSet = new Set(ignoreDirs);
// Process directories in parallel batches for maximum performance
const PARALLEL_BATCH_SIZE = 15; // Parallel processing batch size for optimal performance
while (queueHead < queue.length && scannedDirCount < maxDirs) {
// Fill batch with unvisited directories up to the desired size
const batchSize = Math.min(PARALLEL_BATCH_SIZE, maxDirs - scannedDirCount);
const currentBatch = [];
while (currentBatch.length < batchSize && queueHead < queue.length) {
const currentDir = queue[queueHead];
queueHead++;
if (!visited.has(currentDir)) {
visited.add(currentDir);
currentBatch.push(currentDir);
}
}
scannedDirCount += currentBatch.length;
if (currentBatch.length === 0) continue;
if (debug) {
logger.debug(
`Scanning [${scannedDirCount}/${maxDirs}]: batch of ${currentBatch.length}`,
);
}
// Read directories in parallel instead of one by one
const readPromises = currentBatch.map(async (currentDir) => {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
return { currentDir, entries };
} catch (error) {
// Warn user that a directory could not be read, as this affects search results.
const message = (error as Error)?.message ?? 'Unknown error';
console.warn(
`[WARN] Skipping unreadable directory: ${currentDir} (${message})`,
);
if (debug) {
logger.debug(`Full error for ${currentDir}:`, error);
}
return { currentDir, entries: [] };
}
});
const results = await Promise.all(readPromises);
for (const { currentDir, entries } of results) {
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const isDirectory = entry.isDirectory();
const isMatchingFile = entry.isFile() && entry.name === fileName;
if (!isDirectory && !isMatchingFile) {
continue;
}
if (isDirectory && ignoreDirsSet.has(entry.name)) {
continue;
}
if (
fileService?.shouldIgnoreFile(fullPath, {
respectGitIgnore: options.fileFilteringOptions?.respectGitIgnore,
respectQwenIgnore: options.fileFilteringOptions?.respectQwenIgnore,
})
) {
continue;
}
if (isDirectory) {
queue.push(fullPath);
} else {
foundFiles.push(fullPath);
}
}
}
}
return foundFiles;
}

View File

@@ -209,7 +209,7 @@ describe('loadServerHierarchicalMemory', () => {
});
});
it('should load context files from CWD with custom filename (not subdirectories)', async () => {
it('should load context files by downward traversal with custom filename', async () => {
const customFilename = 'LOCAL_CONTEXT.md';
setGeminiMdFilename(customFilename);
@@ -228,10 +228,9 @@ describe('loadServerHierarchicalMemory', () => {
DEFAULT_FOLDER_TRUST,
);
// Only upward traversal is performed, subdirectory files are not loaded
expect(result).toEqual({
memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---`,
fileCount: 1,
memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---\n\n--- Context from: ${path.join('subdir', customFilename)} ---\nSubdir custom memory\n--- End of Context from: ${path.join('subdir', customFilename)} ---`,
fileCount: 2,
});
});
@@ -260,7 +259,7 @@ describe('loadServerHierarchicalMemory', () => {
});
});
it('should only load context files from CWD, not subdirectories', async () => {
it('should load ORIGINAL_GEMINI_MD_FILENAME files by downward traversal from CWD', async () => {
await createTestFile(
path.join(cwd, 'subdir', DEFAULT_CONTEXT_FILENAME),
'Subdir memory',
@@ -279,14 +278,13 @@ describe('loadServerHierarchicalMemory', () => {
DEFAULT_FOLDER_TRUST,
);
// Subdirectory files are not loaded, only CWD and upward
expect(result).toEqual({
memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---`,
fileCount: 1,
memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---\n\n--- Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---\nSubdir memory\n--- End of Context from: ${path.join('subdir', DEFAULT_CONTEXT_FILENAME)} ---`,
fileCount: 2,
});
});
it('should load and correctly order global and upward context files', async () => {
it('should load and correctly order global, upward, and downward ORIGINAL_GEMINI_MD_FILENAME files', async () => {
const defaultContextFile = await createTestFile(
path.join(homedir, QWEN_DIR, DEFAULT_CONTEXT_FILENAME),
'default context content',
@@ -303,7 +301,7 @@ describe('loadServerHierarchicalMemory', () => {
path.join(cwd, DEFAULT_CONTEXT_FILENAME),
'CWD memory',
);
await createTestFile(
const subDirGeminiFile = await createTestFile(
path.join(cwd, 'sub', DEFAULT_CONTEXT_FILENAME),
'Subdir memory',
);
@@ -317,10 +315,92 @@ describe('loadServerHierarchicalMemory', () => {
DEFAULT_FOLDER_TRUST,
);
// Subdirectory files are not loaded, only global and upward from CWD
expect(result).toEqual({
memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---`,
fileCount: 4,
memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, subDirGeminiFile)} ---\nSubdir memory\n--- End of Context from: ${path.relative(cwd, subDirGeminiFile)} ---`,
fileCount: 5,
});
});
it('should ignore specified directories during downward scan', async () => {
await createEmptyDir(path.join(projectRoot, '.git'));
await createTestFile(path.join(projectRoot, '.gitignore'), 'node_modules');
await createTestFile(
path.join(cwd, 'node_modules', DEFAULT_CONTEXT_FILENAME),
'Ignored memory',
);
const regularSubDirGeminiFile = await createTestFile(
path.join(cwd, 'my_code', DEFAULT_CONTEXT_FILENAME),
'My code memory',
);
const result = await loadServerHierarchicalMemory(
cwd,
[],
false,
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
'tree',
{
respectGitIgnore: true,
respectQwenIgnore: true,
},
200, // maxDirs parameter
);
expect(result).toEqual({
memoryContent: `--- Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---\nMy code memory\n--- End of Context from: ${path.relative(cwd, regularSubDirGeminiFile)} ---`,
fileCount: 1,
});
});
it('should respect the maxDirs parameter during downward scan', async () => {
const consoleDebugSpy = vi
.spyOn(console, 'debug')
.mockImplementation(() => {});
// Create directories in parallel for better performance
const dirPromises = Array.from({ length: 2 }, (_, i) =>
createEmptyDir(path.join(cwd, `deep_dir_${i}`)),
);
await Promise.all(dirPromises);
// Pass the custom limit directly to the function
await loadServerHierarchicalMemory(
cwd,
[],
true,
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
'tree', // importFormat
{
respectGitIgnore: true,
respectQwenIgnore: true,
},
1, // maxDirs
);
expect(consoleDebugSpy).toHaveBeenCalledWith(
expect.stringContaining('[DEBUG] [BfsFileSearch]'),
expect.stringContaining('Scanning [1/1]:'),
);
vi.mocked(console.debug).mockRestore();
const result = await loadServerHierarchicalMemory(
cwd,
[],
false,
new FileDiscoveryService(projectRoot),
[],
DEFAULT_FOLDER_TRUST,
);
expect(result).toEqual({
memoryContent: '',
fileCount: 0,
});
});

View File

@@ -8,9 +8,12 @@ import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import { homedir } from 'node:os';
import { bfsFileSearch } from './bfsFileSearch.js';
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { processImports } from './memoryImportProcessor.js';
import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_MEMORY_FILE_FILTERING_OPTIONS } from '../config/constants.js';
import { QWEN_DIR } from './paths.js';
// Simple console logger, similar to the one previously in CLI's config.ts
@@ -83,6 +86,8 @@ async function getGeminiMdFilePathsInternal(
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions,
maxDirs: number,
): Promise<string[]> {
const dirs = new Set<string>([
...includeDirectoriesToReadGemini,
@@ -104,6 +109,8 @@ async function getGeminiMdFilePathsInternal(
fileService,
extensionContextFilePaths,
folderTrust,
fileFilteringOptions,
maxDirs,
),
);
@@ -132,6 +139,8 @@ async function getGeminiMdFilePathsInternalForEachDir(
fileService: FileDiscoveryService,
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
fileFilteringOptions: FileFilteringOptions,
maxDirs: number,
): Promise<string[]> {
const allPaths = new Set<string>();
const geminiMdFilenames = getAllGeminiMdFilenames();
@@ -176,7 +185,7 @@ async function getGeminiMdFilePathsInternalForEachDir(
// Not found, which is okay
}
} else if (dir && folderTrust) {
// FIX: Only perform the workspace search (upward scan from CWD to project root)
// FIX: Only perform the workspace search (upward and downward scans)
// if a valid currentWorkingDirectory is provided and it's not the home directory.
const resolvedCwd = path.resolve(dir);
if (debugMode)
@@ -216,6 +225,23 @@ async function getGeminiMdFilePathsInternalForEachDir(
currentDir = path.dirname(currentDir);
}
upwardPaths.forEach((p) => allPaths.add(p));
const mergedOptions: FileFilteringOptions = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...fileFilteringOptions,
};
const downwardPaths = await bfsFileSearch(resolvedCwd, {
fileName: geminiMdFilename,
maxDirs,
debug: debugMode,
fileService,
fileFilteringOptions: mergedOptions,
});
downwardPaths.sort();
for (const dPath of downwardPaths) {
allPaths.add(dPath);
}
}
}
@@ -338,6 +364,8 @@ export async function loadServerHierarchicalMemory(
extensionContextFilePaths: string[] = [],
folderTrust: boolean,
importFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
maxDirs: number = 200,
): Promise<LoadServerHierarchicalMemoryResponse> {
if (debugMode)
logger.debug(
@@ -355,6 +383,8 @@ export async function loadServerHierarchicalMemory(
fileService,
extensionContextFilePaths,
folderTrust,
fileFilteringOptions || DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
maxDirs,
);
if (filePaths.length === 0) {
if (debugMode) logger.debug('No QWEN.md files found in hierarchy.');
@@ -370,14 +400,6 @@ export async function loadServerHierarchicalMemory(
contentsWithPaths,
currentWorkingDirectory,
);
// Only count files that match configured memory filenames (e.g., QWEN.md),
// excluding system context files like output-language.md
const memoryFilenames = new Set(getAllGeminiMdFilenames());
const fileCount = contentsWithPaths.filter((item) =>
memoryFilenames.has(path.basename(item.filePath)),
).length;
if (debugMode)
logger.debug(
`Combined instructions length: ${combinedInstructions.length}`,
@@ -388,6 +410,6 @@ export async function loadServerHierarchicalMemory(
);
return {
memoryContent: combinedInstructions,
fileCount, // Only count the context files
fileCount: contentsWithPaths.length,
};
}

View File

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

View File

@@ -0,0 +1,255 @@
# LSP 工具重构计划
## 背景
对比 Claude Code 的 LSP tool 定义和当前实现,发现以下关键差异:
### Claude Code 的设计(目标)
```json
{
"name": "LSP",
"operations": [
"goToDefinition",
"findReferences",
"hover",
"documentSymbol",
"workspaceSymbol",
"goToImplementation",
"prepareCallHierarchy",
"incomingCalls",
"outgoingCalls"
],
"required_params": ["operation", "filePath", "line", "character"]
}
```
### 当前实现
- **分散的 3 个工具**`lsp_go_to_definition`, `lsp_find_references`, `lsp_workspace_symbol`
- **支持 3 个操作**goToDefinition, findReferences, workspaceSymbol
- **缺少 6 个操作**hover, documentSymbol, goToImplementation, prepareCallHierarchy, incomingCalls, outgoingCalls
---
## 重构目标
1. **统一工具设计**:将 3 个分散的工具合并为 1 个统一的 `LSP` 工具
2. **扩展操作支持**:添加缺失的 6 个 LSP 操作
3. **简化参数设计**:统一使用 operation + filePath + line + character 方式
4. **保持向后兼容**:旧工具名称继续支持
---
## 实施步骤
### Step 1: 扩展类型定义
**文件**: `packages/core/src/lsp/types.ts`
新增类型:
```typescript
// Hover 结果
interface LspHoverResult {
contents: string | { language: string; value: string }[];
range?: LspRange;
}
// Call Hierarchy 类型
interface LspCallHierarchyItem {
name: string;
kind: number;
uri: string;
range: LspRange;
selectionRange: LspRange;
detail?: string;
data?: unknown;
serverName?: string;
}
interface LspCallHierarchyIncomingCall {
from: LspCallHierarchyItem;
fromRanges: LspRange[];
}
interface LspCallHierarchyOutgoingCall {
to: LspCallHierarchyItem;
fromRanges: LspRange[];
}
```
扩展 LspClient 接口:
```typescript
interface LspClient {
// 现有方法
workspaceSymbols(query, limit): Promise<LspSymbolInformation[]>;
definitions(location, serverName, limit): Promise<LspDefinition[]>;
references(
location,
serverName,
includeDeclaration,
limit,
): Promise<LspReference[]>;
// 新增方法
hover(location, serverName): Promise<LspHoverResult | null>;
documentSymbols(uri, serverName, limit): Promise<LspSymbolInformation[]>;
implementations(location, serverName, limit): Promise<LspDefinition[]>;
prepareCallHierarchy(location, serverName): Promise<LspCallHierarchyItem[]>;
incomingCalls(
item,
serverName,
limit,
): Promise<LspCallHierarchyIncomingCall[]>;
outgoingCalls(
item,
serverName,
limit,
): Promise<LspCallHierarchyOutgoingCall[]>;
}
```
### Step 2: 创建统一 LSP 工具
**新文件**: `packages/core/src/tools/lsp.ts`
参数设计(采用灵活的操作特定验证):
```typescript
interface LspToolParams {
operation: LspOperation; // 必填
filePath?: string; // 位置类操作必填
line?: number; // 精确位置操作必填 (1-based)
character?: number; // 可选 (1-based)
query?: string; // workspaceSymbol 必填
callHierarchyItem?: object; // incomingCalls/outgoingCalls 必填
serverName?: string; // 可选
limit?: number; // 可选
includeDeclaration?: boolean; // findReferences 可选
}
type LspOperation =
| 'goToDefinition'
| 'findReferences'
| 'hover'
| 'documentSymbol'
| 'workspaceSymbol'
| 'goToImplementation'
| 'prepareCallHierarchy'
| 'incomingCalls'
| 'outgoingCalls';
```
各操作参数要求:
| 操作 | filePath | line | character | query | callHierarchyItem |
|------|----------|------|-----------|-------|-------------------|
| goToDefinition | 必填 | 必填 | 可选 | - | - |
| findReferences | 必填 | 必填 | 可选 | - | - |
| hover | 必填 | 必填 | 可选 | - | - |
| documentSymbol | 必填 | - | - | - | - |
| workspaceSymbol | - | - | - | 必填 | - |
| goToImplementation | 必填 | 必填 | 可选 | - | - |
| prepareCallHierarchy | 必填 | 必填 | 可选 | - | - |
| incomingCalls | - | - | - | - | 必填 |
| outgoingCalls | - | - | - | - | 必填 |
### Step 3: 扩展 NativeLspService
**文件**: `packages/cli/src/services/lsp/NativeLspService.ts`
新增 6 个方法:
1. `hover()` - 调用 `textDocument/hover`
2. `documentSymbols()` - 调用 `textDocument/documentSymbol`
3. `implementations()` - 调用 `textDocument/implementation`
4. `prepareCallHierarchy()` - 调用 `textDocument/prepareCallHierarchy`
5. `incomingCalls()` - 调用 `callHierarchy/incomingCalls`
6. `outgoingCalls()` - 调用 `callHierarchy/outgoingCalls`
### Step 4: 更新工具名称映射
**文件**: `packages/core/src/tools/tool-names.ts`
```typescript
export const ToolNames = {
LSP: 'lsp', // 新增
// 保留旧名称(标记 deprecated
LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol',
LSP_GO_TO_DEFINITION: 'lsp_go_to_definition',
LSP_FIND_REFERENCES: 'lsp_find_references',
} as const;
export const ToolNamesMigration = {
lsp_go_to_definition: ToolNames.LSP,
lsp_find_references: ToolNames.LSP,
lsp_workspace_symbol: ToolNames.LSP,
} as const;
```
### Step 5: 更新 Config 工具注册
**文件**: `packages/core/src/config/config.ts`
- 注册新的统一 `LspTool`
- 保留旧工具注册(向后兼容)
- 可通过配置选项禁用旧工具
### Step 6: 向后兼容处理
**文件**: 现有 3 个 LSP 工具文件
- 添加 `@deprecated` 标记
- 添加 deprecation warning 日志
- 可选:内部转发到新工具实现
---
## 关键文件列表
| 文件路径 | 操作 |
| --------------------------------------------------- | --------------------------- |
| `packages/core/src/lsp/types.ts` | 修改 - 扩展类型定义 |
| `packages/core/src/tools/lsp.ts` | 新建 - 统一 LSP 工具 |
| `packages/core/src/tools/tool-names.ts` | 修改 - 添加工具名称 |
| `packages/cli/src/services/lsp/NativeLspService.ts` | 修改 - 添加 6 个新方法 |
| `packages/core/src/config/config.ts` | 修改 - 注册新工具 |
| `packages/core/src/tools/lsp-*.ts` (3个) | 修改 - 添加 deprecated 标记 |
---
## 验证方式
1. **单元测试**
-`LspTool` 参数验证测试
- 各操作执行逻辑测试
- 向后兼容测试
2. **集成测试**
- TypeScript Language Server 测试所有 9 个操作
- Python LSP 测试
- 多服务器场景测试
3. **手动验证**
- 在 VS Code 中测试各操作
- 验证旧工具名称仍可使用
- 验证 deprecation warning 输出
---
## 风险与缓解
| 风险 | 缓解措施 |
| --------------------------- | -------------------------------------- |
| 部分 LSP 服务器不支持新操作 | 独立 try-catch返回清晰错误消息 |
| Call Hierarchy 两步流程复杂 | 文档说明使用方式,提供示例 |
| 向后兼容增加维护成本 | 设置明确弃用时间线,配置选项控制旧工具 |
---
## 后续优化建议
1. 考虑是否需要支持更多 LSP 操作(如 `textDocument/rename`, `textDocument/formatting`
2. 考虑添加 LSP 服务器能力查询,动态返回支持的操作列表
3. 考虑优化 TypeScript Server warm-up 逻辑,减少首次调用延迟

View File

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

View File

@@ -23,23 +23,3 @@ export const CLIENT_METHODS = {
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;
export const ACP_ERROR_CODES = {
// Parse error: invalid JSON received by server.
PARSE_ERROR: -32700,
// Invalid request: JSON is not a valid Request object.
INVALID_REQUEST: -32600,
// Method not found: method does not exist or is unavailable.
METHOD_NOT_FOUND: -32601,
// Invalid params: invalid method parameter(s).
INVALID_PARAMS: -32602,
// Internal error: implementation-defined server error.
INTERNAL_ERROR: -32603,
// Authentication required: must authenticate before operation.
AUTH_REQUIRED: -32000,
// Resource not found: e.g. missing file.
RESOURCE_NOT_FOUND: -32002,
} as const;
export type AcpErrorCode =
(typeof ACP_ERROR_CODES)[keyof typeof ACP_ERROR_CODES];

View File

@@ -28,7 +28,6 @@ import * as os from 'node:os';
import type { z } from 'zod';
import type { DiffManager } from './diff-manager.js';
import { OpenFilesManager } from './open-files-manager.js';
import { ACP_ERROR_CODES } from './constants/acpSchema.js';
class CORSError extends Error {
constructor(message: string) {
@@ -265,7 +264,7 @@ export class IDEServer {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: ACP_ERROR_CODES.AUTH_REQUIRED,
code: -32000,
message:
'Bad Request: No valid session ID provided for non-initialize request.',
},
@@ -284,7 +283,7 @@ export class IDEServer {
res.status(500).json({
jsonrpc: '2.0' as const,
error: {
code: ACP_ERROR_CODES.INTERNAL_ERROR,
code: -32603,
message: 'Internal server error',
},
id: null,

View File

@@ -5,7 +5,6 @@
*/
import { JSONRPC_VERSION } from '../types/acpTypes.js';
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
import type {
AcpMessage,
AcpPermissionRequest,
@@ -233,34 +232,12 @@ export class AcpConnection {
})
.catch((error) => {
if ('id' in message && typeof message.id === 'number') {
const errorMessage =
error instanceof Error
? error.message
: typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: String(error);
let errorCode: number = ACP_ERROR_CODES.INTERNAL_ERROR;
const errorCodeValue =
typeof error === 'object' && error !== null && 'code' in error
? (error as { code?: unknown }).code
: undefined;
if (typeof errorCodeValue === 'number') {
errorCode = errorCodeValue;
} else if (errorCodeValue === 'ENOENT') {
errorCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND;
}
this.messageHandler.sendResponseMessage(this.child, {
jsonrpc: JSONRPC_VERSION,
id: message.id,
error: {
code: errorCode,
message: errorMessage,
code: -32603,
message: error instanceof Error ? error.message : String(error),
},
});
}

View File

@@ -66,11 +66,6 @@ export class AcpFileHandler {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
const nodeError = error as NodeJS.ErrnoException;
if (nodeError?.code === 'ENOENT') {
throw error;
}
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
}
}

View File

@@ -4,11 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ACP_ERROR_CODES } from '../constants/acpSchema.js';
const AUTH_ERROR_PATTERNS = [
'Authentication required', // Standard authentication request message
`(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`, // RPC error code indicates auth failure
'(code: -32000)', // RPC error code -32000 indicates authentication failure
'Unauthorized', // HTTP unauthorized error
'Invalid token', // Invalid token
'Session expired', // Session expired

View File

@@ -8,9 +8,6 @@ import * as vscode from 'vscode';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import type { ChatMessage } from '../../services/qwenAgentManager.js';
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
import { ACP_ERROR_CODES } from '../../constants/acpSchema.js';
const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`;
/**
* Session message handler
@@ -358,7 +355,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
createErr instanceof Error ? createErr.message : String(createErr);
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN)
errorMsg.includes('(code: -32000)')
) {
await this.promptLogin(
'Your login session has expired or is invalid. Please login again to continue using Qwen Code.',
@@ -424,7 +421,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
errorMsg.includes('Session not found') ||
errorMsg.includes('No active ACP session') ||
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token')
) {
@@ -515,7 +512,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -625,7 +622,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -685,7 +682,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors in session creation
if (
createErrorMsg.includes('Authentication required') ||
createErrorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
createErrorMsg.includes('(code: -32000)') ||
createErrorMsg.includes('Unauthorized') ||
createErrorMsg.includes('Invalid token') ||
createErrorMsg.includes('No active ACP session')
@@ -725,7 +722,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -780,7 +777,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -830,7 +827,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -858,7 +855,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -964,7 +961,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')
@@ -992,7 +989,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
// Check for authentication/session expiration errors
if (
errorMsg.includes('Authentication required') ||
errorMsg.includes(AUTH_REQUIRED_CODE_PATTERN) ||
errorMsg.includes('(code: -32000)') ||
errorMsg.includes('Unauthorized') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('No active ACP session')