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

@@ -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)