fix(lsp): preserve execution-world URI semantics

This commit is contained in:
Tianyi Cui
2026-07-29 04:19:18 +08:00
parent 3537665806
commit 4997fb219e
24 changed files with 162 additions and 107 deletions

View File

@@ -244,10 +244,9 @@ export class LspInstance {
if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) }
}
// `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
// display paths against, not the request's possibly-symlinked workspaceRoot.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
// The filesystem provider owns URI syntax for the execution platform, which may differ from the
// harness host. Preserve that coordinate through rendering instead of reparsing `spec.cwd` there.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceUri: this.spec.workspaceUri }
}
private answerServerRequest(method: string, params: unknown): Promise<unknown> {

View File

@@ -117,17 +117,17 @@ describe('LspInstance server-request handling', () => {
it('accepts a lifecycle client/registerCapability request', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
it('rejects a workspace/applyEdit request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
it('rejects an unknown server request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
})
@@ -263,7 +263,7 @@ describe('LspInstance query and abort', () => {
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations',
locations: [],
resolvedWorkspaceRoot: ws,
resolvedWorkspaceUri: pathToFileURL(ws).href,
})
expect(instance.dead).toBe(true)
})

View File

@@ -102,7 +102,7 @@ describe('lsp-local end to end over a fake server', () => {
expect(result).toEqual<LspQueryResult>({
kind: 'locations',
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
resolvedWorkspaceRoot: ws,
resolvedWorkspaceUri: pathToFileURL(ws).href,
})
await ctx.fiber.dispose()
})
@@ -133,7 +133,7 @@ describe('lsp-local end to end over a fake server', () => {
it('returns an empty locations result for a null definition', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
@@ -173,7 +173,7 @@ describe('lsp-local end to end over a fake server', () => {
it('accepts openClose options sync', async () => {
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
@@ -297,7 +297,7 @@ describe('lsp-local end to end over a fake server', () => {
controller.abort(new Error('mid-read cancel'))
await expect(pending).rejects.toThrow(/mid-read cancel/)
// A subsequent live query still works, proving no half-created instance poisoned the pool.
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp/README.md
README.md: f96fc67ec8cb95f423eff9b312b7b591ec9d3008
README.zh.md: 9757147de692684747d9965efda6329b3bbe2638
README.md: 5c1044be50368acf13d8c36a15d5b2bd99d02701
README.zh.md: cc412333e63b9469319240d67269bf0192ad3858

View File

@@ -27,7 +27,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner
## Vocabulary
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceUri }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceUri` is the provider's canonical workspace `file:` URI; callers relativize location URIs against it instead of applying host-platform path rules to the possibly symlinked request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
## Model Experience

View File

@@ -8,7 +8,7 @@
| 包 | 职责 |
|---|---|
| `@deepseek-ai/dsh-lsp`(本包) | 接口:服务、以品牌类型的 id 扩展名映射为的提供方注册表、逐查询选择、请求/结果词汇、`LspError` 分类体系 |
| `@deepseek-ai/dsh-lsp`(本包) | 接口:服务、以品牌 id + 扩展名映射为 key 的提供方注册表、逐查询选择、请求/结果词汇、`LspError` 分类体系 |
| `@deepseek-ai/dsh-lsp-local` | 通用本地后端,注册已配置的 stdio 语言服务器提供方 |
| `@deepseek-ai/dsh-tool-lsp` | 面向模型的 `lsp` 工具,基于 `ctx.lsp` |
@@ -18,16 +18,16 @@
| 成员 | 语义 |
|---|---|
| `registerProvider(provider)` | 注册后端,以原子方式保留其品牌类型的 `id` 与每个规范化文件扩展名。任何无效输入或冲突都不会发布内容,并抛出 `LspError``LSP_INVALID_PROVIDER``LSP_CONFLICT`)。返回释放所有保留项的 disposer。随调用 fiber 一同 dispose资源释放。 |
| `registerProvider(provider)` | 注册后端,以原子方式保留其品牌 `id` 与每个规范化文件扩展名。任何无效输入或冲突都不会发布内容,并抛出 `LspError``LSP_INVALID_PROVIDER``LSP_CONFLICT`)。返回释放所有保留项的 disposer。随调用 fiber 释放。 |
| `query(request, signal?)` | 按文件最终扩展名选择提供方,从该提供方的映射派生 `languageId`,并运行一次查询。没有匹配项时抛出 `LspError` `LSP_UNAVAILABLE`。 |
选择逐查询进行且与顺序无关:一个提供方独占一组扩展名,因此注册和 HMR(热模块替换)顺序绝不会改变路由。扩展名 key 规范化为小写且以点开头;`languageId` 只用于同步临时文档,绝不参与选择。第一版没有 glob、language-id 或显式路由 selector。
选择逐查询进行且与顺序无关:一个提供方独占一组扩展名,因此注册和 HMR 顺序绝不会改变路由。扩展名 key 规范化为小写且以点开头;`languageId` 只用于同步临时文档,绝不参与选择。第一版没有 glob、language-id 或显式路由 selector。
提供方注册的是**能力**而非工具。`dsh-tool-lsp` 是面向模型名称、描述、提示词指引、schema 和呈现的唯一 owner。
提供方注册的是**能力** 而非工具。`dsh-tool-lsp` 是面向模型名称、描述、提示词指引、schema 和呈现的唯一 owner。
## 词汇
`LspQueryRequest``operation``filePath``position``workspaceRoot`):每个字段都必填,因此没有字段需要实现默认值,也不存在 `resolve()` 步骤。位置与范围使用从零开始的 UTF-16与协议一致工具负责从 1 开始的光标约定。`findReferences` 始终包含声明,提供方在内部强制执行,因此调用方没有 flag。`LspQueryResult` 是封闭的判别联合:导航使用 `{ kind: 'locations'; locations; resolvedWorkspaceRoot }`,悬停使用 `{ kind: 'hover'; hover }`(内容或 `null`);消费方通过 `switch` 实现穷尽检查,因此新增分支会使编译失败,直到完成处理。`resolvedWorkspaceRoot` 是提供方对请求 `workspaceRoot` 的规范形式,也是其 `file:` URI 所相对的根;调用方把显示路径相对化时使用该值,而非可能含符号链接的请求根。完整契约见 `src/types.ts``src/index.ts` 给出 `LspError` 代码,包括 `LSP_DISPOSED``LSP_MALFORMED_RESPONSE`
`LspQueryRequest``operation``filePath``position``workspaceRoot`):每个字段都必填,因此没有字段需要实现默认值,也不存在 `resolve()` 步骤。位置与范围使用从零开始的 UTF-16与协议一致工具拥有从 1 开始的光标约定。`findReferences` 始终包含声明,提供方在内部强制执行,因此调用方没有 flag。`LspQueryResult` 是封闭的判别联合:导航使用 `{ kind: 'locations'; locations; resolvedWorkspaceUri }`,悬停使用 `{ kind: 'hover'; hover }`(内容或 `null`);消费方通过 `switch` 实现穷尽检查,因此新增分支会使编译失败,直到完成处理。`resolvedWorkspaceUri` 是提供方的规范工作区 `file:` URI调用方相对化位置 URI 时以它为基准,而不是对可能含符号链接的请求根应用宿主平台路径规则。完整契约见 `src/types.ts``src/index.ts` 给出 `LspError` code,包括 `LSP_DISPOSED``LSP_MALFORMED_RESPONSE`
## 模型体验
@@ -39,6 +39,6 @@
## 已知限制与暂缓事项
- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使语言 ID 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector它可以放宽互斥保留而无需把提供方选择加入模型输入见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
- **同一运行时内扩展名归属互斥**:两个提供方不能同时声明 `.ts`,即使 language id 不同;重叠会使注册失败。预期扩展是在注册之上增加部署配置的 selector它可以放宽互斥保留而无需把提供方选择加入模型输入见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
- **仅四种操作**symbol 与 call hierarchy 暂缓(它们需要不同 schemadiagnostics 需要独立的新鲜度累积规则修改操作rename、code action、formatting需要独立工具并集成预览、权限和写入策略。
- **没有观测接口**:可用性只能通过运行 `query()` 并按抛出的 `LspError` 代码进行路由来观测;没有提供方变更事件或能力状态查询。
- **没有观测表层**:可用性只能通过运行 `query()` 并按抛出的 `LspError` code 路由来观测;没有提供方变更事件或能力状态查询。

View File

@@ -77,13 +77,13 @@ export interface LspHover {
* `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`.
* Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled.
*
* The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
* request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
* relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`;
* otherwise a symlinked workspace misclassifies in-workspace results as external.
* The `locations` variant carries `resolvedWorkspaceUri`: the provider's canonical `file:` URI for
* the request's workspace root. A caller that relativizes location URIs MUST use this, not parse the
* request's possibly symlinked process path with host-platform rules; the execution platform may
* differ from the caller's.
*/
export type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceUri: string }
| { readonly kind: 'hover'; readonly hover: LspHover | null }
/**

View File

@@ -13,7 +13,7 @@ import Lsp, {
function makeProvider(
id: string,
extensionToLanguage: Record<string, string>,
result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' },
result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' },
): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
const seen: LspProviderQuery[] = []
const seenSignals: (AbortSignal | undefined)[] = []
@@ -63,7 +63,7 @@ describe('Lsp registration', () => {
const provider = makeProvider('ts', { '.ts': 'typescript' })
const dispose = lsp.registerProvider(provider)
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' })
expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' })
dispose()
@@ -148,7 +148,7 @@ describe('Lsp registration', () => {
const py = makeProvider('py', { '.py': 'python' })
lsp.registerProvider(ts)
lsp.registerProvider(py)
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' })
await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover)
})
@@ -172,7 +172,7 @@ describe('Lsp registration', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
}, { inject: ['lsp'] }))
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: 'file:///ws' })
await fiber.dispose()
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/tool-lsp/README.md
README.md: 9b4130015ddf7e1cad6fa9a0e131be86f3bd4bcc
README.zh.md: a3f59fb6e39bb6c19cc2ef06d0bc256633e75386
README.md: 1e89abf22e853735c094d17047b2c946af210905
README.zh.md: 396b4d22ada8761b17c01d0c40e4bb4cfe80ca67

View File

@@ -10,7 +10,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In
`lsp` accepts `operation` (`goToDefinition` | `findReferences` | `goToImplementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input.
The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Its canonical result is the complete normalized seam union: `{ kind: "locations", locations, resolvedWorkspaceRoot }` or `{ kind: "hover", hover }`; Code Mode can inspect every acquired location and zero-based range directly. Native rendering then projects stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors.
The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Its canonical result is the complete normalized seam union: `{ kind: "locations", locations, resolvedWorkspaceUri }` or `{ kind: "hover", hover }`; Code Mode can inspect every acquired location and zero-based range directly. Native rendering projects stable, file-grouped `path:line:character` entries against the provider's canonical workspace URI rather than applying host-platform path rules to the session cwd. A `file:` URI becomes a workspace-relative path inside that URI or a URI-derived absolute path outside it; malformed and non-`file:` URIs stay verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors.
## Configuration

View File

@@ -8,13 +8,13 @@ Namespace 插件(`name``inject``Config``apply`,无默认导出)
## 工具
`lsp` 接受 `operation``goToDefinition` | `findReferences` | `goToImplementation` | `hover`)、`file_path``line``character``line``character` 是正的、从 1 开始的 UTF-16 光标坐标;工具将其转换为 seam 从零开始的位置,并把渲染位置转换回来。`findReferences` 包含声明,因此影响分析不会遗漏定义位置。提供方、语言 ID、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。
`lsp` 接受 `operation``goToDefinition` | `findReferences` | `goToImplementation` | `hover`)、`file_path``line``character``line``character` 是正的、从 1 开始的 UTF-16 光标坐标;工具将其转换为 seam 从零开始的位置,并把渲染位置转换回来。`findReferences` 包含声明,因此影响分析不会遗漏定义位置。提供方、language id、Workspace 根、限制、超时、初始化和可执行文件均不进入模型输入。
该工具要求从会话 `header.cwd` 取得工作区根目录,没有回退值:缺失时会在查询前以 `LSP_WORKSPACE_REQUIRED` 失败。其规范结果是完整的已规范化 seam 联合:`{ kind: "locations", locations, resolvedWorkspaceRoot }``{ kind: "hover", hover }`Code Mode 可以直接检查每个已取得的位置和从零开始的范围。Native 渲染随后投影出稳定的、按文件分组的 `path:line:character` 条目,并相对于结果的 `resolvedWorkspaceRoot`(提供方的规范根目录)而非会话 cwd因此即使 cwd 包含符号链接,工作区内的结果仍渲染为相对路径`file:` URI 位于工作区内时成为工作区相对路径,位于工作区外时成为绝对路径,其他 URI 保持原样。空位置和 `null` hover 都是成功的无结果响应;格式错误的提供方载荷仍是结构化错误。
该工具要求从会话 `header.cwd` 取得 Workspace 根,没有回退值:缺失时会在查询前以 `LSP_WORKSPACE_REQUIRED` 失败。其规范结果是完整的已规范化 seam 联合:`{ kind: "locations", locations, resolvedWorkspaceUri }``{ kind: "hover", hover }`Code Mode 可以直接检查每个已取得的位置和从零开始的范围。原生渲染以提供方的规范工作区 URI 为基准,投影按文件稳定分组的 `path:line:character` 条目,而不对会话 cwd 应用宿主平台路径规则`file:` URI 落在该工作区 URI 内时成为工作区相对路径,位于外时成为从 URI 派生的绝对路径;格式错误的 URI 与非 `file:` URI 保持原样。空位置和 `null` hover 都是成功的无结果响应;格式错误的提供方载荷仍是结构化错误。
## 配置
| 配置键 | 默认值 | 含义 |
| Key | 默认值 | 含义 |
|---|---|---|
| `maxLocations` | `100` | 出现省略标记前可渲染位置的最大数量。 |
| `maxResultChars` | `16000` | 完整渲染结果的最大长度,包括截断元数据。 |
@@ -40,7 +40,7 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu
#### KV Cache 影响
只要插件作用域与指引文本不变,前缀就保持稳定;激活或 dispose资源释放可能使从该区段起的复用失效。
只要插件 scope 与指引文本不变,前缀就保持稳定;激活或释放可能使从该区段起的复用失效。
### 工具 schema
@@ -54,13 +54,13 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu
#### KV Cache 影响
只要可见工具定义与顺序不变,前缀就保持稳定;注册生命周期或作用域限制可能使从第一个变化的 schema token 起的复用失效。
只要可见工具定义与顺序不变,前缀就保持稳定;注册生命周期或 scope 限制可能使从第一个变化的 schema token 起的复用失效。
### 结果
#### 模型看到的内容
按文件分组的 `path:line:character` 位置行或规范化 hover 文本,先由 `maxLocations` 限制,再由 `maxResultChars` 限制;省略与截断标记计入完整字符上限。这些上限只影响 Native/模型呈现,不影响规范值。空结果使用不同的 `No results.``No hover information.` 行。
按文件分组的 `path:line:character` 位置行或规范化 hover 文本,先由 `maxLocations` 限制,再由 `maxResultChars` 限制;省略与截断标记计入完整字符上限。这些上限只影响原生/模型呈现,不影响规范值。空结果使用不同的 `No results.``No hover information.` 行。
#### Token 影响
@@ -74,7 +74,7 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu
#### 模型看到的内容
无。客户端渲染通用搜索卡片:`{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }`;从 args 派生的标题携带操作与从 1 开始的光标,编辑器跟随定位会聚焦所查询行,标题则保留列号。
无。客户端渲染通用搜索卡片:`{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }`;从 args 派生的标题携带操作与从 1 开始的光标,跟随焦点对准查询行,标题则保留列号。
#### Token 影响
@@ -86,5 +86,5 @@ Use search/read for ordinary navigation. Use lsp when textual matches are ambigu
## 已知限制与暂缓事项
- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;不在符号上的位置可能返回空结果,因此提示词解释了该约定,但不会鼓励宽泛使用 LSP见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
- **UTF-16 光标坐标**:列坐标与协议精确一致,但模型难以在非 BMP 字符周围计数;非 symbol 位置可能返回空结果,因此提示词解释了该约定,但不会鼓励宽泛使用 LSP见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md))。
- **不承诺跨服务器完整性**:受支持的服务器仍可能根据索引就绪情况返回空或部分结果;该工具不承诺跨语言或服务器的完整性。

View File

@@ -138,7 +138,7 @@ export function apply(ctx: Context, config: Config): void {
},
},
},
resolvedWorkspaceRoot: { type: 'string', required: true },
resolvedWorkspaceUri: { type: 'string', required: true },
},
},
{
@@ -167,7 +167,7 @@ export function apply(ctx: Context, config: Config): void {
render: (_args, value) => {
switch (value.kind) {
case 'locations':
return [{ type: 'text', text: formatLocations(value.locations, value.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }]
return [{ type: 'text', text: formatLocations(value.locations, value.resolvedWorkspaceUri, resolved.maxLocations, resolved.maxResultChars) }]
case 'hover':
return [{ type: 'text', text: formatHover(value.hover, resolved.maxResultChars) }]
/* v8 ignore next -- exhaustive over the output schema's closed union; unreachable. */
@@ -200,7 +200,7 @@ export function apply(ctx: Context, config: Config): void {
end: { line: location.range.end.line, character: location.range.end.character },
},
})),
resolvedWorkspaceRoot: result.resolvedWorkspaceRoot,
resolvedWorkspaceUri: result.resolvedWorkspaceUri,
}
case 'hover':
return {

View File

@@ -6,8 +6,6 @@
* @module @deepseek-ai/dsh-tool-lsp/render
*/
import { fileURLToPath } from 'node:url'
import { isAbsolute, relative, sep } from 'node:path'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
@@ -74,17 +72,17 @@ function oneBased(value: number, name: string): number {
/**
* Render a locations result grouped by file, converting each zero-based location back to a one-based
* `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path;
* outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and
* outside it, a URI-derived absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and
* appends an omission marker when it truncates by count, then applies the complete result cap.
* @param locations - the seam's locations (possibly empty).
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
* @param workspaceUri - the provider's canonical workspace `file:` URI.
* @param maxLocations - the cap before truncation.
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
* @returns the rendered text; a distinct no-result line when there are none.
*/
export function formatLocations(
locations: readonly LspLocation[],
workspaceRoot: string,
workspaceUri: string,
maxLocations: number,
maxResultChars: number,
): string {
@@ -93,7 +91,7 @@ export function formatLocations(
const omitted = locations.length - shown.length
const grouped = new Map<string, string[]>()
for (const location of shown) {
const path = renderUri(location.uri, workspaceRoot)
const path = renderUri(location.uri, workspaceUri)
const line = location.range.start.line + 1
const character = location.range.start.character + 1
const entries = grouped.get(path) ?? []
@@ -128,27 +126,63 @@ function boundResult(text: string, maxChars: number, label: string): string {
}
/**
* Resolve a location URI to a display path. A `file:` URI accepted by Node becomes workspace-relative
* (inside) or absolute (outside); any other URI is returned verbatim.
* Resolve a location URI without applying the harness host's path rules. A valid `file:` URI becomes
* workspace-relative when it is under the provider's canonical workspace URI, or a URI-derived
* absolute path otherwise; malformed and non-`file:` URIs remain verbatim.
* @param uri - the target URI from the seam.
* @param workspaceRoot - the canonical workspace root.
* @param workspaceUri - the provider's canonical workspace `file:` URI.
* @returns the display path or the verbatim URI.
*/
export function renderUri(uri: string, workspaceRoot: string): string {
export function renderUri(uri: string, workspaceUri: string): string {
if (!uri.startsWith('file:')) return uri
let absolute: string
let target: URL
let workspace: URL
try {
absolute = fileURLToPath(uri)
target = new URL(uri)
workspace = new URL(workspaceUri)
} catch {
// A malformed file: URI is not a path we can resolve; show it verbatim.
return uri
}
const rel = relative(workspaceRoot, absolute)
if (rel === '') return '.'
// A leading `..` SEGMENT (or an absolute rel) means outside the workspace; guard against a false
// positive on an in-workspace path whose first component merely starts with dots (e.g. `..gen/x`).
const outside = rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)
return outside ? absolute : rel.split(sep).join('/')
if (workspace.protocol !== 'file:') return uri
const targetSegments = decodeFileSegments(target)
const workspaceSegments = decodeFileSegments(workspace)
if (targetSegments === undefined || workspaceSegments === undefined) return uri
const sameAuthority = target.hostname === workspace.hostname
const windowsWorld = /^[A-Za-z]:$/.test(workspaceSegments[0] ?? '')
const inside = sameAuthority
&& targetSegments.length >= workspaceSegments.length
&& workspaceSegments.every((segment, index) => samePathSegment(segment, targetSegments[index] as string, windowsWorld))
if (inside) {
const relative = targetSegments.slice(workspaceSegments.length)
return relative.length === 0 ? '.' : relative.join('/')
}
return absoluteUriPath(target, targetSegments, workspaceSegments)
}
/** Decode URI path segments while rejecting encoded separators that would change path structure. */
function decodeFileSegments(url: URL): string[] | undefined {
try {
const decoded = url.pathname.split('/').map(segment => decodeURIComponent(segment))
if (decoded.some(segment => /[/\\\0]/u.test(segment))) return undefined
while (decoded.at(-1) === '') decoded.pop()
decoded.shift()
return decoded
} catch {
return undefined
}
}
/** Windows execution-world path segments are case-insensitive even on a non-Windows harness host. */
function samePathSegment(left: string, right: string, windowsWorld: boolean): boolean {
return windowsWorld ? left.toUpperCase() === right.toUpperCase() : left === right
}
/** Render an external file URL according to the execution-world style implied by its workspace URI. */
function absoluteUriPath(target: URL, segments: readonly string[], workspaceSegments: readonly string[]): string {
if (target.hostname.length > 0) return `//${target.hostname}/${segments.join('/')}`
const windowsWorld = /^[A-Za-z]:$/.test(workspaceSegments[0] ?? '')
if (windowsWorld && /^[A-Za-z]:$/.test(segments[0] ?? '')) return segments.join('/')
return `/${segments.join('/')}`
}
/**

View File

@@ -14,6 +14,7 @@ import {
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
const WS = resolve('/home/u/proj')
const WS_URI = pathToFileURL(WS).href
function loc(uri: string, line: number, character = 0): LspLocation {
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
@@ -48,64 +49,84 @@ describe('parseLspArgs', () => {
describe('renderUri', () => {
it('relativizes a file: URI inside the workspace with forward slashes', () => {
const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href
expect(renderUri(uri, WS)).toBe('src/a.ts')
expect(renderUri(uri, WS_URI)).toBe('src/a.ts')
})
it('returns an absolute path for a file: URI outside the workspace', () => {
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
const uri = pathToFileURL(outside).href
expect(renderUri(uri, WS)).toBe(outside)
expect(renderUri(uri, WS_URI)).toBe(outside)
})
it('renders the workspace root itself as "."', () => {
expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.')
expect(renderUri(WS_URI, WS_URI)).toBe('.')
})
it('keeps an in-workspace path whose first segment starts with dots relative', () => {
// `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external.
const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href
expect(renderUri(uri, WS)).toBe('..generated/a.ts')
expect(renderUri(uri, WS_URI)).toBe('..generated/a.ts')
})
it('relativizes Windows execution-world URIs on a non-Windows host', () => {
expect(renderUri('file:///C:/WORKSPACE/src/a.ts', 'file:///c:/workspace')).toBe('src/a.ts')
expect(renderUri('file:///D:/lib/b.ts', 'file:///C:/workspace')).toBe('D:/lib/b.ts')
})
it('renders remote file authorities without host path conversion', () => {
expect(renderUri('file://server/share/workspace/a.ts', 'file://server/share/workspace')).toBe('a.ts')
expect(renderUri('file://other/share/b.ts', 'file://server/share/workspace')).toBe('//other/share/b.ts')
expect(renderUri('file:///a.ts', 'file://server/')).toBe('/a.ts')
})
it('keeps malformed or mismatched URI coordinates verbatim', () => {
expect(renderUri('file://[', WS_URI)).toBe('file://[')
expect(renderUri('file:///a.ts', 'https://example.com/workspace')).toBe('file:///a.ts')
expect(renderUri('file:///a.ts', 'file:///bad%ZZ')).toBe('file:///a.ts')
expect(renderUri('file:///bad%5Cpath', WS_URI)).toBe('file:///bad%5Cpath')
expect(renderUri('file:///short', 'file:///short/deeper')).toBe('/short')
expect(renderUri('file:///', 'file:///C:/workspace')).toBe('/')
})
it('keeps a non-file URI verbatim', () => {
expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1')
expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class')
expect(renderUri('untitled:Untitled-1', WS_URI)).toBe('untitled:Untitled-1')
expect(renderUri('jdt://contents/Foo.class', WS_URI)).toBe('jdt://contents/Foo.class')
})
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
// An encoded path separator is invalid on every platform and must remain verbatim.
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
expect(renderUri('file:///bad%2Fpath', WS_URI)).toBe('file:///bad%2Fpath')
})
})
describe('formatLocations', () => {
it('renders a no-result line for an empty list', () => {
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
expect(formatLocations([], WS_URI, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
})
it('renders one-based path:line:character grouped by file', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS_URI, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
expect(text).toBe('a.ts:1:1\na.ts:5:3')
})
it('caps at maxLocations and marks the omission', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS)
const text = formatLocations(many, WS_URI, 2, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('a.ts:1:1')
expect(text).toContain('3 more locations omitted (limit 2).')
})
it('uses the singular omission marker for exactly one extra', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS)
const text = formatLocations([loc(a, 0), loc(a, 1)], WS_URI, 1, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('1 more location omitted (limit 1).')
})
it('caps the complete location text even when one URI is enormous', () => {
const maxResultChars = 80
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars)
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS_URI, 1, maxResultChars)
expect(text).toHaveLength(maxResultChars)
expect(text).toContain('locations truncated')
})

View File

@@ -44,6 +44,7 @@ let seq = 0
const testToolSignal = new AbortController().signal
const workspaceRoot = resolve('/virtual/workspace')
const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
const resolvedWorkspaceUri = pathToFileURL(resolvedWorkspaceRoot).href
const workspaceAlias = resolve('/virtual/workspace-alias')
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
@@ -59,7 +60,7 @@ function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
const okLocations: LspQueryResult = {
kind: 'locations',
locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: workspaceRoot,
resolvedWorkspaceUri: pathToFileURL(workspaceRoot).href,
}
describe('tool-lsp registration', () => {
@@ -138,7 +139,7 @@ describe('tool-lsp execution', () => {
const { ctx } = await mount(stubProvider(() => ({
kind: 'locations',
locations,
resolvedWorkspaceRoot: cappedWorkspaceRoot,
resolvedWorkspaceUri: pathToFileURL(cappedWorkspaceRoot).href,
})), { maxLocations: 1 })
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, cappedWorkspaceRoot)
expect(result.content[0]).toEqual({
@@ -147,17 +148,17 @@ describe('tool-lsp execution', () => {
})
expect(result).toMatchObject({
isError: false,
value: { kind: 'locations', locations, resolvedWorkspaceRoot: cappedWorkspaceRoot },
value: { kind: 'locations', locations, resolvedWorkspaceUri: pathToFileURL(cappedWorkspaceRoot).href },
})
})
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
it('relativizes against the provider resolvedWorkspaceUri, not the session cwd', async () => {
// A symlinked session cwd resolves to the real path that contains the provider's location URIs.
// Relativizing against the alias would misclassify the location as external.
const provider = stubProvider(() => ({
kind: 'locations',
locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot,
resolvedWorkspaceUri,
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)