Files
deepseek-harness/packages/lsp/lsp-local/tests/instance.spec.ts
Dudu-0223 d0029d8d60 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.
2026-07-16 12:05:35 +08:00

185 lines
8.4 KiB
TypeScript

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, fileURLToPath } from 'node:url'
import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery } from '@deepseek-ai/dsh-lsp'
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
let root: string
let ws: string
let live: LspInstance[] = []
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-')))
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
})
afterEach(async () => {
for (const instance of live) await instance.dispose()
live = []
await rm(root, { recursive: true, force: true })
})
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
const instance = new LspInstance({
command: process.execPath,
args: ['--import', tsxLoader, fixtureServer],
cwd: ws,
env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
maxDocumentBytes: 4_000_000,
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
})
live.push(instance)
return instance
}
function query(operation: LspProviderQuery['operation'] = 'definition'): LspProviderQuery {
return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
}
/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
const instance = new LspInstance({
command: process.execPath,
args: ['-e', script],
cwd: ws,
env: { ...process.env as Record<string, string> },
configuration: null,
initializationOptions: null,
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
maxDocumentBytes: 4_000_000,
shutdownTimeoutMs: 150,
killGraceMs: 150,
...overrides,
})
live.push(instance)
return instance
}
/** An inline server that answers initialize + definition and echoes a location. */
const RESPONDING_SERVER =
'let b=Buffer.alloc(0);'
+ '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")process.stdout.write(fr({id:m.id,result:null}));'
+ '}});'
const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
describe('LspInstance server-request handling', () => {
it('answers workspace/configuration with the static config per item', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
// The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
// keeps the query working.
await expect(instance.query(query('definition'))).resolves.toMatchObject({ kind: 'locations' })
})
it('accepts a lifecycle client/registerCapability request', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
})
it('rejects a workspace/applyEdit request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
})
it('rejects an unknown server request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
})
})
describe('LspInstance query and abort', () => {
it('sends includeDeclaration for references', async () => {
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
await expect(instance.query(query('references'))).resolves.toMatchObject({ kind: 'locations' })
})
it('rejects a query aborted before it starts', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
controller.abort(new Error('pre-abort'))
await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/pre-abort/)
})
it('cancels an in-flight request on abort and rejects', async () => {
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
const controller = new AbortController()
// Warm the instance first so the abort lands during the hanging request, not during startup.
const pending = instance.query(query('definition'), controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
})
it('rejects when the server lacks the operation capability', async () => {
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
await expect(instance.query(query('definition'))).rejects.toThrow(/does not support definition/)
})
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
// A live signal is passed, but the request fails for a server reason; the catch must rethrow
// without treating it as an abort.
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
const controller = new AbortController()
await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/server refused/)
})
})
describe('LspInstance disposal', () => {
it('is idempotent — a second dispose awaits close without error', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await instance.query(query('definition'))
await instance.dispose()
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('rejects a query after disposal', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await instance.query(query('definition'))
await instance.dispose()
await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/)
})
it('reports dead after the process closes', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await instance.query(query('definition'))
await instance.dispose()
expect(instance.dead).toBe(true)
})
it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => {
// Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await instance.query(query('definition'))
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('carries a non-Error abort reason as a generic aborted error', async () => {
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
const controller = new AbortController()
const pending = instance.query(query('definition'), controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 200))
controller.abort('a string reason, not an Error')
await expect(pending).rejects.toThrow(/aborted/)
})
})