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
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import {
negotiatePositionEncoding,
normalizeHover,
normalizeLocations,
requestMethod,
supportsOperation,
supportsTransientOpen,
} from '@deepseek-ai/dsh-lsp-local'
import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-local/src/protocol.ts'
const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }
describe('requestMethod', () => {
it('maps each operation to its textDocument request', () => {
expect(requestMethod('definition')).toBe('textDocument/definition')
expect(requestMethod('references')).toBe('textDocument/references')
expect(requestMethod('implementation')).toBe('textDocument/implementation')
expect(requestMethod('hover')).toBe('textDocument/hover')
})
})
describe('supportsOperation', () => {
it('reads the provider slot for each operation (boolean and options forms)', () => {
const caps: WireServerCapabilities = {
definitionProvider: true,
referencesProvider: { workDoneProgress: true },
implementationProvider: false,
}
expect(supportsOperation(caps, 'definition')).toBe(true)
expect(supportsOperation(caps, 'references')).toBe(true)
expect(supportsOperation(caps, 'implementation')).toBe(false)
expect(supportsOperation(caps, 'hover')).toBe(false)
})
})
describe('supportsTransientOpen', () => {
it('accepts legacy Full and Incremental enums, rejects None and absent', () => {
expect(supportsTransientOpen(1)).toBe(true)
expect(supportsTransientOpen(2)).toBe(true)
expect(supportsTransientOpen(0)).toBe(false)
expect(supportsTransientOpen(undefined)).toBe(false)
})
it('accepts options with openClose:true and rejects openClose:false', () => {
expect(supportsTransientOpen({ openClose: true })).toBe(true)
expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
})
it('falls back to the change enum when openClose is omitted', () => {
expect(supportsTransientOpen({ change: 1 })).toBe(true)
expect(supportsTransientOpen({ change: 0 })).toBe(false)
expect(supportsTransientOpen({})).toBe(false)
})
})
describe('negotiatePositionEncoding', () => {
it('defaults an omitted encoding to utf-16', () => {
expect(negotiatePositionEncoding(undefined)).toBe('utf-16')
expect(negotiatePositionEncoding('utf-16')).toBe('utf-16')
})
it('rejects any other encoding', () => {
expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/)
})
})
describe('normalizeLocations', () => {
it('returns empty for null and undefined', () => {
expect(normalizeLocations(null)).toEqual([])
expect(normalizeLocations(undefined)).toEqual([])
})
it('maps a single Location', () => {
expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }])
})
it('maps an array of Locations', () => {
const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }])
expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b'])
})
it('maps a LocationLink from targetUri + targetSelectionRange', () => {
const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE }
expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }])
})
it('rejects a non-object entry', () => {
expect(() => normalizeLocations([42])).toThrow(/non-object/)
})
it('rejects an entry that is neither a Location nor a LocationLink', () => {
expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/)
})
it('rejects a Location whose range is not an object', () => {
expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/)
})
it('rejects a Location whose range positions are malformed', () => {
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
})
})
describe('normalizeHover', () => {
it('returns null for null', () => {
expect(normalizeHover(null)).toBeNull()
})
it('reads MarkupContent value and keeps a range', () => {
expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
.toEqual({ contents: '# H', range: RANGE })
})
it('keeps a bare string MarkedString verbatim', () => {
expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' })
})
it('renders a language-tagged MarkedString object as a fenced code block', () => {
expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } }))
.toEqual({ contents: '```ts\nconst x = 1\n```' })
})
it('joins a MarkedString array with one blank line', () => {
expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] }))
.toEqual({ contents: 'a\n\n```ts\nb\n```' })
})
it('drops an empty-contents hover to null', () => {
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
})
it('treats a MarkupContent with a non-string value as empty (null)', () => {
expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull()
})
it('rejects a non-object payload', () => {
expect(() => normalizeHover(42)).toThrow(/was not an object/)
})
it('rejects malformed contents', () => {
expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/)
expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
})
it('rejects a hover with no contents field', () => {
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
})
it('ignores a malformed range and keeps the contents', () => {
expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' })
})
})