fix(lsp): preserve execution-world URI semantics
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 暂缓(它们需要不同 schema);diagnostics 需要独立的新鲜度/累积规则;修改操作(rename、code action、formatting)需要独立工具,并集成预览、权限和写入策略。
|
||||
- **没有观测接口**:可用性只能通过运行 `query()` 并按抛出的 `LspError` 代码进行路由来观测;没有提供方变更事件或能力状态查询。
|
||||
- **没有观测表层**:可用性只能通过运行 `query()` 并按抛出的 `LspError` code 路由来观测;没有提供方变更事件或能力状态查询。
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' }))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))。
|
||||
- **不承诺跨服务器完整性**:受支持的服务器仍可能根据索引就绪情况返回空或部分结果;该工具不承诺跨语言或服务器的完整性。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user