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:
56
packages/lsp/tool-lsp/README.md
Normal file
56
packages/lsp/tool-lsp/README.md
Normal 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.
|
||||
45
packages/lsp/tool-lsp/package.json
Normal file
45
packages/lsp/tool-lsp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
130
packages/lsp/tool-lsp/src/index.ts
Normal file
130
packages/lsp/tool-lsp/src/index.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
158
packages/lsp/tool-lsp/src/render.ts
Normal file
158
packages/lsp/tool-lsp/src/render.ts
Normal 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 }],
|
||||
}
|
||||
}
|
||||
19
packages/lsp/tool-lsp/src/session-cwd.ts
Normal file
19
packages/lsp/tool-lsp/src/session-cwd.ts
Normal 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
|
||||
}
|
||||
93
packages/lsp/tool-lsp/tests/integration.spec.ts
Normal file
93
packages/lsp/tool-lsp/tests/integration.spec.ts
Normal 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)
|
||||
})
|
||||
24
packages/lsp/tool-lsp/tests/load-path.spec.ts
Normal file
24
packages/lsp/tool-lsp/tests/load-path.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
125
packages/lsp/tool-lsp/tests/render.spec.ts
Normal file
125
packages/lsp/tool-lsp/tests/render.spec.ts
Normal 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 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
164
packages/lsp/tool-lsp/tests/tool-lsp.spec.ts
Normal file
164
packages/lsp/tool-lsp/tests/tool-lsp.spec.ts
Normal 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 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
33
packages/lsp/tool-lsp/tsconfig.json
Normal file
33
packages/lsp/tool-lsp/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user