fix(lsp): harden local provider lifecycle

This commit is contained in:
Dudu-0223
2026-07-16 16:42:32 +08:00
parent 3368f7924f
commit 0d8e7e98f7
21 changed files with 192 additions and 52 deletions

View File

@@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In
`lsp` accepts `operation` (`definition` | `references` | `implementation` | `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. `references` 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. Locations render as stable, file-grouped `path:line:character` entries; 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. Locations render as 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.
## Configuration

View File

@@ -113,7 +113,10 @@ export function apply(ctx: Context, config: Config): void {
}, exec.signal)
switch (result.kind) {
case 'locations':
return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }]
// Relativize against the provider's canonical workspace root (which its file: URIs are
// relative to), not the session cwd: a symlinked cwd would otherwise misclassify every
// in-workspace location as external and render it as an absolute path.
return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations) }]
case 'hover':
return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
}

View File

@@ -51,6 +51,7 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
const okLocations: LspQueryResult = {
kind: 'locations',
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/ws',
}
describe('tool-lsp registration', () => {
@@ -107,6 +108,21 @@ describe('tool-lsp execution', () => {
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
// location URIs are under. Relativizing against the alias would misclassify the location as
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
const provider = stubProvider(() => ({
kind: 'locations',
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/real/ws',
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('renders hover content', async () => {
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')