feat(lsp): LSP capability seam, generic stdio provider, and lsp tool

Implements the LSP capability seam RFC as three packages: dsh-lsp (the
ctx.lsp interface — provider registry by branded id + exclusive extension
mapping, per-query order-independent selection, closed request/result
vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server
provider — Content-Length JSON-RPC framing, per-(provider, workspace) process
single-flight, transient didOpen/query/didClose, an abortable per-instance
queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and
bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool —
four operations, one-based UTF-16 cursor conversion, workspace-grouped location
rendering, hover capping, a required session workspace, and a timeout budget).

Why: an agent had text search and file reads but no way to identify a program
symbol — follow an alias, connect an interface to implementations, or read an
inferred type — before changing code. Splitting model contract, seam, and local
subprocess behavior keeps the four semantic queries stable across future remote
or sandbox-native providers without leaking a JSON-RPC escape hatch.
This commit is contained in:
Dudu-0223
2026-07-16 12:05:35 +08:00
parent 66b2cfc609
commit d0029d8d60
56 changed files with 4527 additions and 30 deletions

View File

@@ -0,0 +1,56 @@
# @deepseek-ai/dsh-tool-lsp
The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and ACP presentation; it imports no provider.
Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`.
## The tool
`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.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `maxLocations` | `100` | Largest number of rendered locations before an omission marker. |
| `maxHoverChars` | `16000` | Largest hover length in characters, applied after normalization. |
| `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. |
## Model Experience
### Prompt guidance
**What the model sees**: One system-prompt section (order 112) positioning LSP as a precision aid, plus the tool schema below.
**Token effect**: Fixed — the verbatim prose below is contributed once per request while the tool is enabled.
#### Verbatim text for this context surface
```markdown
Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.
```
### Tool schema
**What the model sees**: The model sees the generated [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp).
**Token effect**: Fixed per request while enabled; the `timeoutMs` budget is never sent to the model.
### Results
**What the model sees**: File-grouped `path:line:character` location lines, or normalized hover text; capped by `maxLocations` / `maxHoverChars` with an omission marker when truncated, and distinct `No results.` / `No hover information.` lines for empty results.
**Token effect**: Capped by the two limits above.
### ACP presentation
**What the model sees**: A generic search card — `{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }` — whose args-derived title carries the operation and one-based cursor; follow-along focuses the queried line while the title preserves the column. Rendered by the client, not sent to the model.
**Token effect**: Zero direct token effect (client-side rendering only).
## Known Limitations and Deferred Work
- **UTF-16 cursor coordinates** — columns are exact for the protocol but hard for a model to count around non-BMP characters; an off-symbol position may return empty results, so the prompt explains the convention without encouraging broad LSP use ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
- **No cross-server completeness promise** — supported servers may return empty or partial results depending on indexing readiness; the tool promises no completeness across languages or servers.

View File

@@ -0,0 +1,45 @@
{
"name": "@deepseek-ai/dsh-tool-lsp",
"description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with definition/references/implementation/hover operations, one-based UTF-16 cursor coordinates, workspace-grouped location rendering, and hover normalization",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-lsp-local": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,130 @@
/**
* Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
* (`definition`/`references`/`implementation`/`hover`); it converts one-based UTF-16 cursor
* coordinates to the seam's zero-based positions, requires the session workspace with no fallback,
* caps and renders results, and attaches a configurable timeout budget for `dsh-timeout-policy` to
* enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and imports no provider.
*
* Namespace plugin (named exports, no default export).
* @module @deepseek-ai/dsh-tool-lsp
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { LspError } from '@deepseek-ai/dsh-lsp'
import type {} from '@deepseek-ai/dsh-lsp'
import type {} from '@deepseek-ai/dsh-system-prompt'
import {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
} from './render.ts'
import { sessionCwd } from './session-cwd.ts'
export {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
renderUri,
} from './render.ts'
export { sessionCwd } from './session-cwd.ts'
/** Cordis plugin name for loader diagnostics. */
export const name = 'tool-lsp'
/** Services required by this plugin. */
export const inject = ['tools', 'lsp', 'systemPrompt']
/** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */
export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000
/** The stable system-prompt guidance positioning LSP as a precision aid. */
export const LSP_PROMPT_TEXT =
'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.'
/** Plugin configuration: result caps and the timeout budget. */
export interface Config {
/** Largest number of rendered locations before an omission marker (default 100). */
maxLocations?: number
/** Largest hover length in characters after normalization (default 16000). */
maxHoverChars?: number
/** Tool-call timeout budget in ms (default 60000). */
timeoutMs?: number
}
export const Config: z<Config> = z.object({
maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS),
maxHoverChars: z.number().default(DEFAULT_MAX_HOVER_CHARS),
timeoutMs: z.number().default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
})
type ResolvedConfig = Required<Config>
/**
* Register the `lsp` tool and its system-prompt guidance.
* @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`).
* @param config - the resolved plugin configuration.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveInteger('maxLocations', resolved.maxLocations)
assertPositiveInteger('maxHoverChars', resolved.maxHoverChars)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT })
ctx.tools.register(defineTool({
name: 'lsp',
description:
'Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.',
parameters: {
operation: {
type: 'string',
required: true,
enum: [...LSP_OPERATIONS],
description: 'definition, references, implementation, or hover.',
},
file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' },
line: { type: 'number', required: true, description: 'One-based line of the cursor.' },
character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' },
},
timeoutMs: resolved.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseLspArgs(args)
const workspaceRoot = sessionCwd(exec)
if (workspaceRoot === undefined) {
throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED')
}
const result = await ctx.lsp.query({
operation: input.operation,
filePath: input.filePath,
position: input.position,
workspaceRoot,
}, exec.signal)
switch (result.kind) {
case 'locations':
return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }]
case 'hover':
return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
}
},
presentCall: presentLspCall,
}))
}
/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`tool-lsp: ${name} must be a positive integer`)
}
}

View File

@@ -0,0 +1,158 @@
/**
* Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor
* conversion, workspace-grouped location rendering with `file:`-URI resolution, hover capping, and
* ACP presentation. No I/O — a UI may call the presenter on live streaming and on replay, so it
* depends only on the tool arguments.
* @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'
/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
export const LSP_OPERATIONS: readonly LspOperation[] = ['definition', 'references', 'implementation', 'hover']
/** Default cap on rendered locations before an omission marker is appended. */
export const DEFAULT_MAX_LOCATIONS = 100
/** Default cap on hover characters (applied after normalization) before truncation is marked. */
export const DEFAULT_MAX_HOVER_CHARS = 16_000
/** Validated `lsp` arguments after coordinate checks. */
export interface LspToolInput {
readonly operation: LspOperation
readonly filePath: string
/** Zero-based UTF-16 position converted from the one-based model coordinates. */
readonly position: LspPosition
}
/** The raw, schema-typed argument shape. */
export interface LspToolArgs {
readonly operation: string
readonly file_path: string
readonly line: number
readonly character: number
}
/**
* Validate and convert model arguments: `operation` must be one of the four; `line`/`character` are
* positive one-based integers converted to the seam's zero-based position.
* @param args - the schema-validated raw arguments.
* @returns the validated input with a zero-based position.
* @throws Error when the operation is unknown or a coordinate is not a positive integer.
*/
export function parseLspArgs(args: LspToolArgs): LspToolInput {
if (!isOperation(args.operation)) {
throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`)
}
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
const line = oneBased(args.line, 'line')
const character = oneBased(args.character, 'character')
return {
operation: args.operation,
filePath: args.file_path,
// The model counts from 1; the seam (and protocol) count from 0.
position: { line: line - 1, character: character - 1 },
}
}
/** Whether a string is one of the four operations. */
function isOperation(value: string): value is LspOperation {
return (LSP_OPERATIONS as readonly string[]).includes(value)
}
/** Validate a one-based coordinate is a positive integer. */
function oneBased(value: number, name: string): number {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`${name} must be a positive integer (one-based)`)
}
return value
}
/**
* 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
* appends an omission marker when it truncates.
* @param locations - the seam's locations (possibly empty).
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
* @param maxLocations - the cap before truncation.
* @returns the rendered text; a distinct no-result line when there are none.
*/
export function formatLocations(
locations: readonly LspLocation[],
workspaceRoot: string,
maxLocations: number,
): string {
if (locations.length === 0) return 'No results.'
const shown = locations.slice(0, maxLocations)
const omitted = locations.length - shown.length
const grouped = new Map<string, string[]>()
for (const location of shown) {
const path = renderUri(location.uri, workspaceRoot)
const line = location.range.start.line + 1
const character = location.range.start.character + 1
const entries = grouped.get(path) ?? []
entries.push(`${path}:${line}:${character}`)
grouped.set(path, entries)
}
const lines: string[] = []
for (const entries of grouped.values()) lines.push(...entries)
if (omitted > 0) {
lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`)
}
return lines.join('\n')
}
/**
* Render a hover result, applying `maxHoverChars` last and marking truncation.
* @param hover - the normalized hover, or `null` for no hover.
* @param maxHoverChars - the cap applied after normalization.
* @returns the rendered hover text; a distinct no-result line for `null`.
*/
export function formatHover(hover: LspHover | null, maxHoverChars: number): string {
if (hover === null) return 'No hover information.'
const contents = hover.contents
if (contents.length <= maxHoverChars) return contents
return `${contents.slice(0, maxHoverChars)}\n… hover truncated (limit ${maxHoverChars} characters).`
}
/**
* 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.
* @param uri - the target URI from the seam.
* @param workspaceRoot - the canonical workspace root.
* @returns the display path or the verbatim URI.
*/
export function renderUri(uri: string, workspaceRoot: string): string {
if (!uri.startsWith('file:')) return uri
let absolute: string
try {
absolute = fileURLToPath(uri)
} 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 '.'
const outside = rel.startsWith('..') || isAbsolute(rel)
return outside ? absolute : rel.split(sep).join('/')
}
/**
* ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the
* operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has
* no character, so the title preserves the column).
* @param args - the raw tool arguments.
* @returns the generic call view.
*/
export function presentLspCall(args: LspToolArgs): GenericCallView {
return {
card: 'generic',
kind: 'search',
title: `LSP ${args.operation} ${args.file_path}:${args.line}:${args.character}`,
locations: [{ path: args.file_path, line: args.line }],
}
}

View File

@@ -0,0 +1,19 @@
/**
* Derive the workspace root an `lsp` call resolves against: the calling agent's per-session
* workspace (`exec.agent.session.header.cwd`), mirroring how the filesystem tools resolve paths.
* Unlike those tools, LSP has NO provider fallback — a missing cwd fails the call as
* `LSP_WORKSPACE_REQUIRED`, because the local provider must canonicalize a real workspace before it
* can start a server.
* @module @deepseek-ai/dsh-tool-lsp/session-cwd
*/
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
/**
* The session workspace cwd for this call, or `undefined` when none applies.
* @param exec - the tool-execution context; only its optional `agent` is read.
* @returns the calling agent's session cwd, or undefined for a non-agent caller.
*/
export function sessionCwd(exec: ToolExecution): string | undefined {
return exec.agent?.session.header.cwd
}

View File

@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
/**
* Real-composition integration: the model-facing `lsp` tool over the real seam, the real
* `dsh-lsp-local` provider (driving an inline stdio server), and the real `dsh-timeout-policy`, all
* driven only through `ctx.tools.execute()`. Pins that a query round-trips end to end and that the
* policy's `TOOL_TIMEOUT` budget wins when the server hangs.
*/
let root: string
let ws: string
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-')))
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
/** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */
function serverScript(hang: boolean): string {
const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
return 'let b=Buffer.alloc(0);'
+ `const DEF=${definition};`
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
+ `else if(m.method==="textDocument/definition"){${hang ? '' : 'process.stdout.write(fr({id:m.id,result:DEF}));'}}`
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
+ 'else if(m.method==="exit")process.exit(0);'
+ '}});'
}
async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
await ctx.plugin(LspLocal, {
providerId: 'inline',
command: process.execPath,
args: ['-e', serverScript(hang)],
extensionToLanguage: { '.ts': 'typescript' },
shutdownTimeoutMs: 200,
killGraceMs: 200,
})
await ctx.plugin(TimeoutPolicy)
await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {})
return ctx
}
let seq = 0
function call(ctx: Context, args: unknown) {
return ctx.tools.execute({
callId: `int-${++seq}` as never,
name: 'lsp',
arguments: args,
agent: { session: { header: { cwd: ws } } } as never,
})
}
describe('tool-lsp real composition', () => {
it('round-trips a definition query through the real provider and renders a location', async () => {
const ctx = await mount(false)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
await ctx.fiber.dispose()
}, 30_000)
it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => {
const ctx = await mount(true, 300)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('TOOL_TIMEOUT')
await ctx.fiber.dispose()
}, 30_000)
})

View File

@@ -0,0 +1,24 @@
/**
* Real-load-path guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a
* stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the
* bare `apply`, dropping `inject` (postmortem 0001). This unwraps through the REAL
* `Loader.prototype.unwrapExports` and verifies the namespace shape survives.
*/
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as toolLsp from '@deepseek-ai/dsh-tool-lsp'
describe('dsh-tool-lsp real-load-path guard', () => {
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
expect('default' in toolLsp).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolLsp) as Record<string, unknown>
expect(unwrapped).toBe(toolLsp)
expect(unwrapped.name).toBe('tool-lsp')
expect(unwrapped.inject).toEqual(['tools', 'lsp', 'systemPrompt'])
expect(typeof unwrapped.apply).toBe('function')
expect(unwrapped.Config).toBeDefined()
})
})

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
renderUri,
} from '@deepseek-ai/dsh-tool-lsp'
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
const WS = '/home/u/proj'
function loc(uri: string, line: number, character = 0): LspLocation {
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
}
describe('parseLspArgs', () => {
it('accepts the four operations and converts one-based to zero-based', () => {
for (const operation of LSP_OPERATIONS) {
const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 })
expect(input.operation).toBe(operation)
expect(input.position).toEqual({ line: 2, character: 4 })
}
})
it('rejects an unknown operation', () => {
expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }))
.toThrow(/operation must be one of/)
})
it('rejects a blank file_path', () => {
expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 }))
.toThrow(/file_path/)
})
it('rejects non-positive or non-integer coordinates', () => {
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/)
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/)
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/)
})
})
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')
})
it('returns an absolute path for a file: URI outside the workspace', () => {
const uri = pathToFileURL('/other/lib/b.ts').href
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
})
it('renders the workspace root itself as "."', () => {
expect(renderUri(pathToFileURL(WS).href, WS)).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')
})
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
})
})
describe('formatLocations', () => {
it('renders a no-result line for an empty list', () => {
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS)).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)
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)
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)
expect(text).toContain('1 more location omitted (limit 1).')
})
})
describe('formatHover', () => {
it('renders a no-result line for null', () => {
expect(formatHover(null, DEFAULT_MAX_HOVER_CHARS)).toBe('No hover information.')
})
it('returns short hover verbatim', () => {
expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_HOVER_CHARS)).toBe('```ts\nx: number\n```')
})
it('caps hover at maxHoverChars and marks truncation', () => {
const text = formatHover({ contents: 'a'.repeat(50) }, 10)
expect(text.startsWith('aaaaaaaaaa\n')).toBe(true)
expect(text).toContain('hover truncated (limit 10 characters).')
})
})
describe('presentLspCall', () => {
it('is a generic search card with an operation/cursor title and a line location', () => {
expect(presentLspCall({ operation: 'references', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
card: 'generic',
kind: 'search',
title: 'LSP references a.ts:3:7',
locations: [{ path: 'a.ts', line: 3 }],
})
})
})

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp'
/** A scripted provider recording queries; `respond` yields the result or throws. */
function stubProvider(
respond: (request: LspProviderQuery) => LspQueryResult,
extensionToLanguage: Record<string, string> = { '.ts': 'typescript' },
): LspProvider & { seen: LspProviderQuery[] } {
const seen: LspProviderQuery[] = []
return {
id: LspProviderId('stub'),
extensionToLanguage,
seen,
query(request) {
seen.push(request)
return Promise.resolve(respond(request))
},
}
}
/** Mount the real tool stack over a real seam plus one stub provider. */
async function mount(
provider?: LspProvider,
config: ToolLsp.Config = {},
): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(Lsp)
if (provider) (ctx.lsp as Lsp).registerProvider(provider)
await ctx.plugin(ToolLsp, config)
return { ctx }
}
let seq = 0
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
return ctx.tools.execute({
callId: `c-${++seq}` as never,
name: 'lsp',
arguments: args,
...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {},
})
}
const okLocations: LspQueryResult = {
kind: 'locations',
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
}
describe('tool-lsp registration', () => {
it('registers the lsp tool and its prompt section', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
expect(ctx.tools.get('lsp')).toBeDefined()
const prompt = await ctx.systemPrompt.assemble()
const text = prompt.sections.map(s => s.text).join('\n')
expect(text).toContain(LSP_PROMPT_TEXT)
})
it('attaches the default timeout budget to the tool definition', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS)
})
it('honors a configured timeout override', async () => {
const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 })
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000)
})
it('exposes exactly the four operations in the schema enum', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } }
expect(schema.properties.operation.enum).toEqual(['definition', 'references', 'implementation', 'hover'])
})
it('has no default export (namespace plugin shape)', () => {
expect((ToolLsp as { default?: unknown }).default).toBeUndefined()
})
it('rejects a non-positive config value at load', async () => {
await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/)
})
})
describe('tool-lsp execution', () => {
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
const provider = stubProvider(() => okLocations)
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
expect(result.isError).toBe(false)
expect(provider.seen[0]).toMatchObject({
operation: 'definition',
filePath: 'a.ts',
position: { line: 2, character: 4 },
workspaceRoot: '/ws',
})
})
it('renders locations relative to the workspace', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'references', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
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')
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
})
it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, null)
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED')
})
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
})
it('returns a structured INVALID_ARGS on a bad operation', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('INVALID_ARGS')
})
it('forwards exec.signal to the seam query', async () => {
const seen: (AbortSignal | undefined)[] = []
const provider: LspProvider = {
id: LspProviderId('sig'),
extensionToLanguage: { '.ts': 'typescript' },
query(_request, signal) {
seen.push(signal)
return Promise.resolve(okLocations)
},
}
const { ctx } = await mount(provider)
await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
// undefined); the point is the tool threads it through without throwing.
expect(seen).toHaveLength(1)
})
it('presentCall renders the pending card from args', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 })
expect(view).toEqual({
card: 'generic',
kind: 'search',
title: 'LSP hover a.ts:2:3',
locations: [{ path: 'a.ts', line: 2 }],
})
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../lsp"
}
]
}