fix(lsp): preserve execution-world URI semantics
This commit is contained in:
@@ -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