Files
deepseek-harness/packages/lsp/lsp-local/tests/framing.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

77 lines
3.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-local'
/** Frame a message the way a server would, for decoder round-trips. */
function frame(body: string): Buffer {
return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')])
}
describe('encodeMessage', () => {
it('prefixes a Content-Length header with the utf-8 byte length', () => {
const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } })
const text = buffer.toString('utf8')
const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}'
expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
})
})
describe('MessageDecoder', () => {
it('decodes a single framed message', () => {
const decoder = new MessageDecoder(1_000)
expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }])
})
it('decodes multiple messages arriving in one chunk', () => {
const decoder = new MessageDecoder(1_000)
const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')])
expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }])
})
it('reassembles a message split across chunks', () => {
const decoder = new MessageDecoder(1_000)
const full = frame('{"hello":"world"}')
expect(decoder.push(full.subarray(0, 10))).toEqual([])
expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }])
})
it('handles a header split from its body', () => {
const decoder = new MessageDecoder(1_000)
const body = '{"x":1}'
expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([])
expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }])
})
it('reads a case-insensitive header and ignores other headers', () => {
const decoder = new MessageDecoder(1_000)
const body = '{"ok":true}'
const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8')
expect(decoder.push(chunk)).toEqual([{ ok: true }])
})
it('rejects a body over the size limit', () => {
const decoder = new MessageDecoder(4)
expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/)
})
it('rejects a missing Content-Length header', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/)
})
it('rejects a non-numeric Content-Length', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/)
})
it('rejects a header block that never terminates', () => {
const decoder = new MessageDecoder(1_000)
const huge = Buffer.alloc((1 << 16) + 1, 0x41)
expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
})
it('rejects a non-JSON body', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
})
})