From 66b2cfc6098f4fe1ee795707ccf0ab87a781fea3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 15 Jul 2026 17:34:24 +0800 Subject: [PATCH 01/15] docs(rfc): propose LSP capability seam --- docs/rfc/INDEX.md | 1 + .../2026-07-15-lsp-capability-seam.i18n.yaml | 6 + .../2026-07-15-lsp-capability-seam.md | 198 ++++++++++++++++++ .../2026-07-15-lsp-capability-seam.zh.md | 198 ++++++++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml create mode 100644 docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md create mode 100644 docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a374e795bc..a0dd29d0b5 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -28,6 +28,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | +| [LSP capability seam and model-facing query tool](proposed/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml new file mode 100644 index 0000000000..75d69a5dcb --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-15-lsp-capability-seam.md: 89e58c3ae0ba9f49ed0164a76a230b141296eee5 +2026-07-15-lsp-capability-seam.zh.md: c1c2448b1e980fc17a347f6c434e8553639304f3 diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md new file mode 100644 index 0000000000..89e58c3ae0 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md @@ -0,0 +1,198 @@ +# RFC: LSP capability seam and model-facing query tool + +Status: proposed + +English | [中文](2026-07-15-lsp-capability-seam.zh.md) + +## Problem + +The harness has text search and file reads, but neither identifies a program symbol. A textual match cannot reliably distinguish two same-named functions, follow an import alias, connect an interface to its implementations, or report an inferred type. Before changing code, an agent therefore lacks the semantic navigation that a human gets from an editor's language server. + +LSP support has three owners: the model needs a stable query schema, the harness needs provider selection and normalized results, and the local implementation needs process, JSON-RPC, workspace, synchronization, and filesystem behavior. Combining them would bind the model contract to local subprocesses and obstruct remote or sandbox-native providers. + +Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index. + +## Proposal + +Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: + +1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors. +2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. Multiple plugin instances may register different server commands and extension-to-language-id mappings. +3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation. + +`dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. + +The model and seam expose exactly `definition`, `references`, `implementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. + +The prompt positions LSP as a precision aid: `Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` + +## Package and ownership boundaries + +`dsh-lsp` registers providers by branded id and extension-to-language-id mapping. `registerProvider()` atomically reserves the id and every normalized extension: invalid input or any conflict publishes nothing, and its disposer releases all reservations. Provider plugins register through `ctx.effect()`. Selection is per query and order-independent; no match returns a structured unavailable error. The first version has no glob, language-id, or explicit route selector and no statically declared operation capabilities. + +The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` validates, selects, and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. + +The intended contract shape is: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspProviderId = Branded<'LspProviderId'> + +interface LspPosition { + readonly line: number + readonly character: number +} + +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +interface LspQueryRequest { + readonly operation: LspOperation + readonly filePath: string + readonly position: LspPosition + readonly workspaceRoot: string +} + +interface LspProviderQuery extends LspQueryRequest { + readonly languageId: string +} + +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } + +interface LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +interface LspService { + registerProvider(provider: LspProvider): () => void + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`. The seam exposes no protocol types, process or document controls, or generic request escape hatch. + +`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. + +## Model-facing contract + +The single `lsp` tool accepts: + +```ts +interface LspToolInput { + readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly file_path: string + readonly line: number + readonly character: number +} +``` + +`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` 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 remain outside model input. + +The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace. + +Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100`, and `maxHoverChars` defaults to `16_000` after hover normalization; both report omissions. Empty locations and `null` hover are successful no-result responses; malformed payloads remain structured errors. + +ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure. + +## Timeout ownership + +`dsh-tool-lsp` attaches one configurable `timeoutMs` budget, default `60_000`, to the tool definition. `dsh-timeout-policy` enforces it and supplies `exec.signal`, which reaches `ctx.lsp.query`; the budget covers the complete queued open/query/close lifecycle and is not model-configurable. + +The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. + +Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) before hard kill; the same bounds govern failed-instance cleanup. It uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. + +## Workspace, filesystem, and document synchronization + +`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one handle through validation and reading. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy. + +The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers. + +The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`. + +1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs. +2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. +3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. +4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. + +Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-instance queue serializes complete lifecycles; distinct instances may run in parallel. The server's workspace index remains responsible for closed files reached from the source. + +The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider. + +## Local server lifecycle and protocol behavior + +`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; resolution stays lazy and launch uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter. + +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits. + +Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Hover normalization takes `MarkupContent.value`, preserves string `MarkedString` values, renders language-tagged values as fenced code, joins arrays with one blank line, and applies `maxHoverChars` last. + +Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence. + +## Deliberately deferred surface + +Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation. + +Diagnostics need separate freshness, accumulation, and transcript rules. Mutations such as rename, code actions, and formatting require separate tools with preview, permission, and write-policy integration. + +The local provider trusts its configured server and claims no sandbox confinement. Supporting untrusted binaries requires a later process/filesystem contract for workspace reads plus private cache and temporary writes; restricted, remote, or virtual workspaces require another provider. + +## Alternatives considered + +**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the proposal aligns only on the four semantic queries. + +**Let providers register tools.** Loaded servers would then control model schema and prompts, preventing one stable contract across local and remote providers. + +**Expose arbitrary LSP methods.** A JSON-RPC escape hatch would leak protocol payloads and admit unreviewed mutation or command execution; the operation union stays closed. + +**Expose `resolve(request)` / `query(spec)`.** With no defaulted fields, resolution would only expose provider selection, and a public spec could outlive provider disposal or replacement. One operation keeps selection and invocation atomic to the registration lifetime. + +**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it. + +**Read through `ctx.fs` or the `read` tool.** This could mix the document with a server index from another filesystem namespace; tool output is also windowed, numbered, and observed. The host-local provider reads unobserved full text beside its subprocess. + +**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine. + +**Configure phase timeouts.** Nested timers create competing classifications and fresh budgets. One caller-owned deadline covers query work; only out-of-call teardown keeps local bounds. + +**Query without `didOpen`.** Although permitted, support is inconsistent and may use stale server state. Transient open supplies an explicit current snapshot. + +**Add routes or select the first match.** Registration order and HMR timing are not product semantics, while a route table duplicates unique extension ownership. Overlaps therefore fail registration. + +**Run concurrent queries in one instance.** If cancellation fails, terminating the shared process would kill unrelated work. Per-instance serialization limits that blast radius; instances remain parallel. + +**Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets. + +## Acceptance criteria + +- Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication. +- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. +- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. +- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `references.includeDeclaration`. +- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection. +- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. +- Lifecycle tests pin startup single-flight, per-instance serialization, cross-instance parallelism, abortable queues, crash replacement without replay, and quiescent disposal. +- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. +- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping. +- Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup. +- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change. + +## Risks + +Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. + +Transient opens repeat parsing and notifications. Per-instance serialization increases latency under parallel agents, and long-lived workspace processes consume memory until disposal. + +Extension ownership is exclusive within one runtime. Two providers cannot both claim `.ts`, even with different language ids; this is a conscious MVP limit. The intended extension is a deployment-configured selector above registrations that can relax exclusive reservations without adding provider choice to model input or changing `LspProvider.query`. + +UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use. + +Direct Node access aligns the query snapshot with the server index but bypasses `ctx.fs` and its policy. Canonical containment rejects source files outside the workspace; a trusted server may still read the workspace and use caches. The first implementation therefore requires trusted host-local deployment and provides no sandbox guarantee. diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md new file mode 100644 index 0000000000..c1c2448b1e --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -0,0 +1,198 @@ +# RFC: LSP 能力服务边界与面向模型的查询工具 + +Status: proposed + +[English](2026-07-15-lsp-capability-seam.md) | 中文 + +## 问题 + +harness 已具备文本搜索与文件读取能力,但二者都无法识别程序符号。文本匹配无法可靠地区分同名函数、跟踪导入别名、关联接口与具体实现,也无法报告推断类型。因此,agent(智能体)在修改代码前缺少人类通过编辑器语言服务器获得的语义导航能力。 + +语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程,并阻碍远程或沙箱原生提供方。 + +许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 + +## 提案 + +将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: + +1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 +2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。 +3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACP(Agent Client Protocol)展示。 + +`dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 + +模型与服务边界仅公开 `definition`、`references`、`implementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。 + +提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` + +## Package 与职责边界 + +`dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。 + +服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行校验、选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 + +预期契约如下: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspProviderId = Branded<'LspProviderId'> + +interface LspPosition { + readonly line: number + readonly character: number +} + +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +interface LspQueryRequest { + readonly operation: LspOperation + readonly filePath: string + readonly position: LspPosition + readonly workspaceRoot: string +} + +interface LspProviderQuery extends LspQueryRequest { + readonly languageId: string +} + +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } + +interface LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +interface LspService { + registerProvider(provider: LspProvider): () => void + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 + +`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 + +## 面向模型的契约 + +单一 `lsp` 工具接受以下参数: + +```ts +interface LspToolInput { + readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly file_path: string + readonly line: number + readonly character: number +} +``` + +`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`references` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。 + +工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。 + +位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,`maxHoverChars` 在 `hover` 归一化后应用,默认值为 `16_000`;两者都会报告省略数量。空位置与 `null` hover 是成功的无结果响应;格式错误的载荷保持为结构化错误。 + +ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。 + +## 超时归属 + +`dsh-tool-lsp` 将一个可配置的 `timeoutMs` 预算附加到工具定义,默认值为 `60_000`。`dsh-timeout-policy` 执行预算并提供传入 `ctx.lsp.query` 的 `exec.signal`;该预算覆盖排队、打开、查询和关闭的完整生命周期,模型不可配置。 + +服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 + +提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`)限制强制终止前的宽限期;失败实例的清理也使用相同边界。它使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 + +## 工作区、文件系统与文档同步 + +`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一句柄。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 + +`read` 工具的输出带窗口与行号,进入 transcript 且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 + +本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 + +1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。 +2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。 +3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 +4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 + +每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个实例使用一个可取消队列串行执行完整生命周期;不同实例可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。 + +规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。 + +## 本地服务器生命周期与协议行为 + +`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败,解析保持懒执行,启动不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 + +初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 + +导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。`hover` 归一化直接采用 `MarkupContent.value`,保留字符串 `MarkedString`,把带语言标签的值渲染为围栏代码块,以一个空行连接数组,并在最后应用 `maxHoverChars`。 + +取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。 + +## 明确延后的接口 + +符号操作因需要不同 schema 且与读取或搜索重叠而延后;未来的工作区符号工具必须接收搜索词。调用层级因支持度不一而延后,`prepareCallHierarchy` 仍是内部准备步骤,不是模型操作。 + +诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。 + +本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区并写入私有缓存与临时目录的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。 + +## 备选方案 + +**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此本提案只对齐四种语义查询。 + +**允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一契约。 + +**公开任意 LSP 方法。** JSON-RPC 逃生口会泄露协议载荷,并允许未经评审的变更或命令执行;操作联合保持封闭。 + +**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。 + +**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。 + +**通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合;工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。 + +**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。 + +**配置分阶段超时。** 嵌套定时器会产生相互竞争的分类与新预算。一个由调用方负责的截止时间覆盖查询;只有调用外清理保留本地限制。 + +**不发送 `didOpen`。** 协议虽允许,但支持不一致且可能使用陈旧服务器状态。临时打开提供明确的当前快照。 + +**增加路由或选择首个匹配项。** 注册顺序与 HMR 时机不是产品语义,路由表又会重复唯一扩展名所有权。因此,扩展名重叠时注册失败。 + +**在一个实例中并发查询。** 取消失败时,终止共享进程会杀死无关工作。实例内串行可限制影响范围;不同实例仍可并行。 + +**内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。 + +## 验收标准 + +- Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 +- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 +- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 +- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `references.includeDeclaration`。 +- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 +- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 +- 生命周期测试固定启动 single-flight、实例内串行、跨实例并行、可取消队列、崩溃后不重放的替换,以及释放后完全停稳。 +- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 +- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。 +- 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 +- Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 + +## 风险 + +各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。 + +临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。 + +同一运行时内的扩展名所有权互斥。即使 language id 不同,两个提供方也不能同时占用 `.ts`;这是有意接受的 MVP 限制。预期扩展方式是在注册之上增加由部署配置的 selector,允许放宽互斥占用,同时不向模型输入增加提供方选择,也不改变 `LspProvider.query`。 + +UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。 + +直接访问 Node 文件系统会对齐查询快照与服务器索引,但绕过 `ctx.fs` 及其策略。规范路径 containment 会拒绝工作区外的源文件;受信任的服务器仍可读取工作区并使用缓存。因此,第一版要求受信任的 host-local 部署,不提供沙箱保证。 From d0029d8d609d297ede039cc579fed8cc89d1705c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 12:05:35 +0800 Subject: [PATCH 02/15] feat(lsp): LSP capability seam, generic stdio provider, and lsp tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 15 +- docs/architecture.md | 1 + docs/config-catalog.md | 55 ++++ docs/module-graph.md | 18 ++ docs/rfc/INDEX.md | 2 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 8 +- .../2026-07-15-lsp-capability-seam.zh.md | 8 +- docs/tool-catalog.md | 47 +++ knip.json | 5 + packages/README.md | 16 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/lsp/README.md | 13 + packages/lsp/lsp-local/README.md | 49 +++ packages/lsp/lsp-local/package.json | 43 +++ packages/lsp/lsp-local/src/connection.ts | 240 ++++++++++++++ packages/lsp/lsp-local/src/framing.ts | 99 ++++++ packages/lsp/lsp-local/src/host.ts | 104 +++++++ packages/lsp/lsp-local/src/index.ts | 255 +++++++++++++++ packages/lsp/lsp-local/src/instance.ts | 293 ++++++++++++++++++ packages/lsp/lsp-local/src/protocol.ts | 80 +++++ packages/lsp/lsp-local/src/translate.ts | 210 +++++++++++++ packages/lsp/lsp-local/tests/built-lib.e2e.ts | 73 +++++ .../lsp/lsp-local/tests/connection.spec.ts | 226 ++++++++++++++ .../lsp/lsp-local/tests/fixture-server.ts | 144 +++++++++ packages/lsp/lsp-local/tests/framing.spec.ts | 76 +++++ packages/lsp/lsp-local/tests/host.spec.ts | 105 +++++++ packages/lsp/lsp-local/tests/instance.spec.ts | 184 +++++++++++ .../lsp/lsp-local/tests/lifecycle.spec.ts | 200 ++++++++++++ packages/lsp/lsp-local/tests/provider.spec.ts | 78 +++++ .../lsp/lsp-local/tests/translate.spec.ts | 153 +++++++++ .../lsp-local/tests/typescript-server.e2e.ts | 111 +++++++ packages/lsp/lsp-local/tsconfig.json | 33 ++ packages/lsp/lsp/README.md | 38 +++ packages/lsp/lsp/package.json | 34 ++ packages/lsp/lsp/src/brand.ts | 21 ++ packages/lsp/lsp/src/index.ts | 156 ++++++++++ packages/lsp/lsp/src/types.ts | 124 ++++++++ packages/lsp/lsp/tests/lsp.spec.ts | 187 +++++++++++ packages/lsp/lsp/tsconfig.json | 24 ++ packages/lsp/tool-lsp/README.md | 56 ++++ packages/lsp/tool-lsp/package.json | 45 +++ packages/lsp/tool-lsp/src/index.ts | 130 ++++++++ packages/lsp/tool-lsp/src/render.ts | 158 ++++++++++ packages/lsp/tool-lsp/src/session-cwd.ts | 19 ++ .../lsp/tool-lsp/tests/integration.spec.ts | 93 ++++++ packages/lsp/tool-lsp/tests/load-path.spec.ts | 24 ++ packages/lsp/tool-lsp/tests/render.spec.ts | 125 ++++++++ packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 164 ++++++++++ packages/lsp/tool-lsp/tsconfig.json | 33 ++ pnpm-lock.yaml | 147 ++++++++- scripts/gen-tool-catalog.ts | 16 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 1 + tsconfig.build.json | 5 +- tsconfig.json | 5 +- 56 files changed, 4527 insertions(+), 30 deletions(-) rename docs/rfc/{proposed => implemented}/architecture/2026-07-15-lsp-capability-seam.i18n.yaml (65%) rename docs/rfc/{proposed => implemented}/architecture/2026-07-15-lsp-capability-seam.md (99%) rename docs/rfc/{proposed => implemented}/architecture/2026-07-15-lsp-capability-seam.zh.md (99%) create mode 100644 packages/lsp/README.md create mode 100644 packages/lsp/lsp-local/README.md create mode 100644 packages/lsp/lsp-local/package.json create mode 100644 packages/lsp/lsp-local/src/connection.ts create mode 100644 packages/lsp/lsp-local/src/framing.ts create mode 100644 packages/lsp/lsp-local/src/host.ts create mode 100644 packages/lsp/lsp-local/src/index.ts create mode 100644 packages/lsp/lsp-local/src/instance.ts create mode 100644 packages/lsp/lsp-local/src/protocol.ts create mode 100644 packages/lsp/lsp-local/src/translate.ts create mode 100644 packages/lsp/lsp-local/tests/built-lib.e2e.ts create mode 100644 packages/lsp/lsp-local/tests/connection.spec.ts create mode 100644 packages/lsp/lsp-local/tests/fixture-server.ts create mode 100644 packages/lsp/lsp-local/tests/framing.spec.ts create mode 100644 packages/lsp/lsp-local/tests/host.spec.ts create mode 100644 packages/lsp/lsp-local/tests/instance.spec.ts create mode 100644 packages/lsp/lsp-local/tests/lifecycle.spec.ts create mode 100644 packages/lsp/lsp-local/tests/provider.spec.ts create mode 100644 packages/lsp/lsp-local/tests/translate.spec.ts create mode 100644 packages/lsp/lsp-local/tests/typescript-server.e2e.ts create mode 100644 packages/lsp/lsp-local/tsconfig.json create mode 100644 packages/lsp/lsp/README.md create mode 100644 packages/lsp/lsp/package.json create mode 100644 packages/lsp/lsp/src/brand.ts create mode 100644 packages/lsp/lsp/src/index.ts create mode 100644 packages/lsp/lsp/src/types.ts create mode 100644 packages/lsp/lsp/tests/lsp.spec.ts create mode 100644 packages/lsp/lsp/tsconfig.json create mode 100644 packages/lsp/tool-lsp/README.md create mode 100644 packages/lsp/tool-lsp/package.json create mode 100644 packages/lsp/tool-lsp/src/index.ts create mode 100644 packages/lsp/tool-lsp/src/render.ts create mode 100644 packages/lsp/tool-lsp/src/session-cwd.ts create mode 100644 packages/lsp/tool-lsp/tests/integration.spec.ts create mode 100644 packages/lsp/tool-lsp/tests/load-path.spec.ts create mode 100644 packages/lsp/tool-lsp/tests/render.spec.ts create mode 100644 packages/lsp/tool-lsp/tests/tool-lsp.spec.ts create mode 100644 packages/lsp/tool-lsp/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 22c1f47aa8..edc5f6a74a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools + lsp/ LSP seam + stdio provider + lsp tool skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend @@ -90,13 +91,13 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only. +- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). -- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. -- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. +- **Model-visible ⟺ logged**: anything reaching a model request must be reconstructable from the session log; a new model-visible input requires a session event. +- **Plugins, not loop changes**: new behavior goes on documented extension seams; changing `agent-loop` requires updating docs/architecture.md. - **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively. - **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template). - **No hardcoded tunables in plugins**: deployment choices are defaulted, validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed. @@ -119,15 +120,15 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, ## Type safety and documentation -Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class. +Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep docs at the declaring seam, protocol, or class. -Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified exceptions rather than disabling a rule globally. +Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, with narrow justified exceptions rather than disabling a rule. -Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). +Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document current state not history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). ## Editing these instructions -`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space. +`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling only when the contract needs more space. ## Vendoring policy diff --git a/docs/architecture.md b/docs/architecture.md index b17c3302d8..db3cc19f22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,6 +28,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | +| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | language-server provider registry and semantic navigation | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57e3576d05..a38130a739 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -419,6 +419,42 @@ export interface Config { Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts) +## `@deepseek-ai/dsh-lsp-local` + +Requires: `lsp` + +```ts config-catalog +/** Plugin configuration: one server command plus its extension mapping and host bounds. */ +export interface Config { + /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ + providerId: string + /** Executable to spawn (absolute, or resolved on PATH at load). */ + command: string + /** Arguments passed to the executable (no shell). */ + args: string[] + /** Extra env vars merged on top of the scrubbed ambient env. */ + env: Record + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + extensionToLanguage: Record + /** Static `initialize` options forwarded to the server. */ + initializationOptions: unknown + /** Static answer to every `workspace/configuration` item. */ + configuration: unknown + /** Largest single framed message accepted from the server (bytes). */ + maxMessageBytes: number + /** Largest stderr tail retained for diagnostics (bytes). */ + maxStderrBytes: number + /** Largest source file this host will open (bytes). */ + maxDocumentBytes: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). */ + shutdownTimeoutMs: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ + killGraceMs: number +} +``` + +Source: [`packages/lsp/lsp-local/src/index.ts:59`](../packages/lsp/lsp-local/src/index.ts) + ## `@deepseek-ai/dsh-mcp-client` Requires: `tools` @@ -901,6 +937,24 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) +## `@deepseek-ai/dsh-tool-lsp` + +Requires: `tools` · `lsp` · `systemPrompt` + +```ts config-catalog +/** 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 +} +``` + +Source: [`packages/lsp/tool-lsp/src/index.ts:56`](../packages/lsp/tool-lsp/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` @@ -1214,6 +1268,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) +- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 1b804e5214..f1bc52eabc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -115,6 +115,11 @@ flowchart TD subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end + subgraph group_lsp["packages/lsp"] + pkg_lsp["lsp"] + pkg_lsp_local["lsp-local"] + pkg_tool_lsp["tool-lsp"] + end subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end @@ -139,6 +144,8 @@ flowchart TD pkg_fs --> pkg_brand pkg_fs --> pkg_llm pkg_web --> pkg_llm + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_llm pkg_sandbox --> pkg_llm pkg_agent --> pkg_brand pkg_agent --> pkg_llm @@ -162,6 +169,10 @@ flowchart TD pkg_session_persistence --> pkg_session pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_lsp_local --> pkg_brand + pkg_lsp_local --> pkg_llm + pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_timeout pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_bash_local --> pkg_bash @@ -273,6 +284,10 @@ flowchart TD pkg_tool_ask_user --> pkg_user_interaction pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_tools + pkg_tool_lsp --> pkg_llm + pkg_tool_lsp --> pkg_lsp + pkg_tool_lsp --> pkg_system_prompt + pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_tools pkg_tool_workflow --> pkg_agent @@ -370,6 +385,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | @@ -383,6 +399,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -412,6 +429,7 @@ flowchart TD | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | +| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a0dd29d0b5..4587338244 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -28,7 +28,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [LSP capability seam and model-facing query tool](proposed/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 | ### Process @@ -146,6 +145,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | | [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | +| [LSP capability seam and model-facing query tool](implemented/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml similarity index 65% rename from docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml rename to docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 75d69a5dcb..f8b32dcca1 100644 --- a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 89e58c3ae0ba9f49ed0164a76a230b141296eee5 -2026-07-15-lsp-capability-seam.zh.md: c1c2448b1e980fc17a347f6c434e8553639304f3 +2026-07-15-lsp-capability-seam.md: 90cc7fce8ce86582cc27bd70fadcc46309438983 +2026-07-15-lsp-capability-seam.zh.md: 12873d33684255b7177a78dd4588e11aa61eb26b diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md similarity index 99% rename from docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md rename to docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index 89e58c3ae0..90cc7fce8c 100644 --- a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -1,6 +1,6 @@ # RFC: LSP capability seam and model-facing query tool -Status: proposed +Status: implemented English | [中文](2026-07-15-lsp-capability-seam.zh.md) @@ -12,7 +12,7 @@ LSP support has three owners: the model needs a stable query schema, the harness Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index. -## Proposal +## Decision Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: @@ -171,7 +171,7 @@ The local provider trusts its configured server and claims no sandbox confinemen **Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets. -## Acceptance criteria +## Testing - Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication. - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. @@ -185,7 +185,7 @@ The local provider trusts its configured server and claims no sandbox confinemen - Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup. - Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change. -## Risks +## Consequences Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. diff --git a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md similarity index 99% rename from docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md rename to docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index c1c2448b1e..12873d3368 100644 --- a/docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -1,6 +1,6 @@ # RFC: LSP 能力服务边界与面向模型的查询工具 -Status: proposed +Status: implemented [English](2026-07-15-lsp-capability-seam.md) | 中文 @@ -12,7 +12,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 -## 提案 +## 决策 将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: @@ -171,7 +171,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p **内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。 -## 验收标准 +## 测试 - Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 @@ -185,7 +185,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 - Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 -## 风险 +## 影响 各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 9ea71d3005..91be6752d6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,6 +20,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | @@ -371,6 +372,52 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. +## `@deepseek-ai/dsh-tool-lsp` + +### `lsp` + +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. + +```json +{ + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "definition, references, implementation, or hover.", + "enum": [ + "definition", + "references", + "implementation", + "hover" + ] + }, + "file_path": { + "type": "string", + "description": "The source file to query, relative to the workspace or absolute." + }, + "line": { + "type": "number", + "description": "One-based line of the cursor." + }, + "character": { + "type": "number", + "description": "One-based UTF-16 column of the cursor." + } + }, + "required": [ + "operation", + "file_path", + "line", + "character" + ] +} +``` + +Source: [`packages/lsp/tool-lsp/src/index.ts`](../packages/lsp/tool-lsp/src/index.ts) + +The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. + ## `@deepseek-ai/dsh-tool-skill` ### `skill` diff --git a/knip.json b/knip.json index ad33bec613..76634b0b78 100644 --- a/knip.json +++ b/knip.json @@ -118,6 +118,11 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"] + }, + "packages/lsp/lsp-local": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["typescript-language-server"] } } } diff --git a/packages/README.md b/packages/README.md index 5cb4cfe419..fafa269514 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,10 +1,10 @@ # Packages -Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). +Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md), [root](../AGENTS.md#conventions). ## Hierarchy -Packages are grouped by modular role at `packages///`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code. +Packages are grouped by modular role at `packages///`. The group directory is a pure container (no `package.json`); the package name stays `@deepseek-ai/dsh-` regardless of group. **Each group README is the canonical per-package map** — roles, ctx keys, and the product-vs-support split live there, next to the code. | Group | Role | Release expectation | |---|---|---| @@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface | | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | +| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`context/`](context/README.md) | Opt-in request-context enrichment | Product — stable surface | @@ -23,20 +24,19 @@ Packages are grouped by modular role at `packages///`. The group dir | [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface | | [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface | -| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | +| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect live plugins/services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | +| [`ui/`](ui/README.md) | Editor/client integration: ACP bridge, JSON-RPC SDK server, app packages, user-approval/interaction seams, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | -The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table). +The split is the point: a package's group says whether it is product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a top-level group is a deliberate act (extend the group READMEs and this table). ## Dependencies The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). +The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other spine plugins). The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). -The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). - -Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). +Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index ad739173c9..448a14d5d0 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'lsp', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/lsp/README.md b/packages/lsp/README.md new file mode 100644 index 0000000000..57a1f6b8d9 --- /dev/null +++ b/packages/lsp/README.md @@ -0,0 +1,13 @@ +# lsp/ - LSP capability family + +The language-server capability seam: an abstract LSP interface, a generic stdio provider, and the model-facing `lsp` tool. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` | +| `lsp-local/` | Generic stdio language-server provider (spawn, JSON-RPC, transient-open queries) | (registers on `ctx.lsp`) | +| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | + +The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. + +See the [LSP capability seam RFC](../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md new file mode 100644 index 0000000000..f9e4b32713 --- /dev/null +++ b/packages/lsp/lsp-local/README.md @@ -0,0 +1,49 @@ +# @deepseek-ai/dsh-lsp-local + +A **generic stdio language-server provider** for `ctx.lsp`. One plugin instance configures one server command and its extension-to-language-id map; load multiple instances for multiple servers. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in composition plugins or `cordis.yml` overlays. + +Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). + +## What it does + +- Lazily single-flights one server process per `(provider id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. +- Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel. +- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `providerId` | (required) | Stable provider id reserved on `ctx.lsp` with the extensions. | +| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | +| `args` | `[]` | Arguments passed to the executable. | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | +| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | +| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | +| `configuration` | `null` | Static answer to every `workspace/configuration` item. | +| `maxMessageBytes` | `16000000` | Largest single framed message accepted from the server. | +| `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | +| `maxDocumentBytes` | `4000000` | Largest source file this host will open. | +| `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | +| `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. | + +The executable is resolved at load (after credential scrubbing); a missing command fails before registration. The process itself launches lazily on the first matching query. + +## Protocol behavior + +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. + +## Security boundary + +The provider trusts its configured server and claims no sandbox confinement. It canonicalizes and reads source through Node APIs, rejecting a source that is missing, non-regular, non-UTF-8, oversized, or whose canonical path resolves outside the canonical workspace (symlink aliases share one instance). Result locations may be external, but an external path cannot become a query source. The first implementation therefore requires trusted host-local deployment; restricted, remote, or virtual workspaces require another provider. + +## Model Experience + +Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized results; this host contributes no prompt or schema itself. + +## Known Limitations and Deferred Work + +- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. +- **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json new file mode 100644 index 0000000000..d437e91109 --- /dev/null +++ b/packages/lsp/lsp-local/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-lsp-local", + "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open definition/references/implementation/hover queries in the host filesystem namespace", + "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-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-lsp": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7", + "typescript": "^6.0.3", + "typescript-language-server": "^5.0.0" + } +} diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts new file mode 100644 index 0000000000..1eeb430d22 --- /dev/null +++ b/packages/lsp/lsp-local/src/connection.ts @@ -0,0 +1,240 @@ +/** + * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound + * requests/notifications, and inbound server→client requests: it answers `workspace/configuration` + * from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs + * commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the + * child handle so the instance owns process-signal teardown. + * @module @deepseek-ai/dsh-lsp-local/connection + */ + +import type { ChildProcessByStdio } from 'node:child_process' +import { spawn } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' +import { encodeMessage, MessageDecoder } from './framing.ts' + +/** How to launch the server and answer its config requests. */ +export interface ConnectionSpec { + /** The resolved absolute executable path (no shell). */ + readonly command: string + /** Arguments passed to the executable. */ + readonly args: readonly string[] + /** The child's working directory (the canonical workspace). */ + readonly cwd: string + /** The child's environment (credential-scrubbed, with overrides applied). */ + readonly env: Record + /** Largest single framed message accepted from the server. */ + readonly maxMessageBytes: number + /** Largest stderr tail retained for diagnostics. */ + readonly maxStderrBytes: number + /** Static answer to every `workspace/configuration` item. */ + readonly configuration: unknown +} + +interface Pending { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +/** A live JSON-RPC endpoint bound to one child process. */ +export class LspConnection { + private readonly child: ChildProcessByStdio + private readonly decoder: MessageDecoder + private readonly pending = new Map() + private nextId = 1 + private stderr = '' + private closeReason: Error | undefined + /** Set once the process has fully exited; the instance awaits it during teardown. */ + readonly closed: Promise + + /** + * @param spec - how to launch the server and answer its config requests. + * @param onServerRequest - answers a server→client request; rejects to send an error response. + */ + constructor( + private readonly spec: ConnectionSpec, + private readonly onServerRequest: (method: string, params: unknown) => Promise, + ) { + this.decoder = new MessageDecoder(spec.maxMessageBytes) + this.child = spawn(spec.command, [...spec.args], { + cwd: spec.cwd, + env: spec.env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + this.closed = new Promise((resolve) => { + this.child.on('close', () => { + const reason = this.closeReason ?? new Error('language server exited') + // Record the reason so any request issued AFTER close rejects immediately instead of hanging + // (a closed process sends no further responses). + this.closeReason = reason + this.failAll(reason) + resolve() + }) + }) + this.child.on('error', (error) => { this.fail(error) }) + // A write to the child's stdin after it exits emits an async 'error'; swallow it so an EPIPE + // during teardown does not crash the process. Pending requests fail via the 'close' handler. + /* v8 ignore next -- the handler only fires on an async stdin write error during teardown. */ + this.child.stdin.on('error', () => { /* swallow */ }) + this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) + this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) + } + + /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */ + get pid(): number { + /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */ + return this.child.pid ?? -1 + } + + /** The retained stderr tail, for diagnostics on a failed server. */ + get stderrTail(): string { + return this.stderr + } + + /** + * Send a request and await its result. + * @param method - the JSON-RPC method. + * @param params - the request params. + * @returns the response result; rejects on an error response, write failure, or close. + */ + request(method: string, params: unknown): Promise { + const id = this.nextId++ + const promise = new Promise((resolve, reject) => { + if (this.closeReason !== undefined) { + reject(this.closeReason) + return + } + this.pending.set(id, { resolve, reject }) + try { + this.write({ jsonrpc: '2.0', id, method, params }) + } catch (error) { + /* v8 ignore start -- a stdin write failure surfaces asynchronously via the swallowed + 'error' listener, so this synchronous catch is a defensive guard. */ + this.pending.delete(id) + reject(asError(error)) + /* v8 ignore stop */ + } + }) + // A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later + // when the process closes; a benign no-op handler keeps that from surfacing as an unhandled + // rejection. The returned promise still delivers the rejection to the caller's own await/catch. + promise.catch(() => {}) + return promise + } + + /** + * Send a notification (no id, no response). + * @param method - the JSON-RPC method. + * @param params - the notification params. + */ + notify(method: string, params: unknown): void { + this.write({ jsonrpc: '2.0', method, params }) + } + + /** + * Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure). + * @param requestId - the numeric id of the request to cancel. + */ + cancel(requestId: number): void { + try { + this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }) + } catch { + // The server is already gone or unwritable; the pending request will fail on close. + } + } + + /** + * The id the NEXT `request()` will use, so the instance can pre-arm a cancel. + * @returns the numeric id the next request will be assigned. + */ + peekNextId(): number { + return this.nextId + } + + /** Send SIGTERM to the child (idempotent-safe; a dead child ignores it). */ + terminate(): void { + this.child.kill('SIGTERM') + } + + /** Send SIGKILL to the child. */ + kill(): void { + this.child.kill('SIGKILL') + } + + private onStdout(chunk: Buffer): void { + let messages: unknown[] + try { + messages = this.decoder.push(chunk) + } catch (error) { + // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance. + this.fail(asError(error)) + this.child.kill('SIGKILL') + return + } + for (const message of messages) this.dispatch(message) + } + + private onStderr(chunk: Buffer): void { + if (this.stderr.length >= this.spec.maxStderrBytes) return + this.stderr = (this.stderr + chunk.toString('utf8')).slice(0, this.spec.maxStderrBytes) + } + + private dispatch(message: unknown): void { + if (message === null || typeof message !== 'object') return + const frame = message as Record + const id = frame.id + const method = frame.method + if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) { + void this.handleServerRequest(id, method, frame.params) + return + } + if (typeof method === 'string') { + // A server→client notification (e.g. diagnostics, logs): ignored by this MVP host. + return + } + if (typeof id === 'number') this.handleResponse(id, frame) + } + + private async handleServerRequest(id: number | string, method: string, params: unknown): Promise { + try { + const result = await this.onServerRequest(method, params) + this.write({ jsonrpc: '2.0', id, result }) + } catch (error) { + this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } }) + } + } + + private handleResponse(id: number, frame: Record): void { + const pending = this.pending.get(id) + if (!pending) return + this.pending.delete(id) + const error = frame.error + if (error !== null && typeof error === 'object') { + const record = error as Record + pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response')) + return + } + pending.resolve(frame.result) + } + + private write(message: unknown): void { + this.child.stdin.write(encodeMessage(message)) + } + + private fail(error: Error): void { + /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */ + if (this.closeReason === undefined) this.closeReason = error + this.failAll(error) + } + + private failAll(error: Error): void { + const waiting = [...this.pending.values()] + this.pending.clear() + for (const pending of waiting) pending.reject(error) + } +} + +/** Coerce an unknown thrown value to an `Error`. */ +function asError(value: unknown): Error { + /* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */ + return value instanceof Error ? value : new Error(String(value)) +} diff --git a/packages/lsp/lsp-local/src/framing.ts b/packages/lsp/lsp-local/src/framing.ts new file mode 100644 index 0000000000..8720247272 --- /dev/null +++ b/packages/lsp/lsp-local/src/framing.ts @@ -0,0 +1,99 @@ +/** + * LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder + * produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies, + * bounding the header and total message size so a hostile or broken server cannot exhaust memory. + * @module @deepseek-ai/dsh-lsp-local/framing + */ + +/** The header/body separator in the LSP base protocol. */ +const HEADER_SEPARATOR = '\r\n\r\n' + +/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */ +const MAX_HEADER_BYTES = 1 << 16 + +/** + * Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n`). + * @param message - the JSON-RPC message object to serialize. + * @returns the framed bytes ready to write to the server's stdin. + */ +export function encodeMessage(message: unknown): Buffer { + const body = Buffer.from(JSON.stringify(message), 'utf8') + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii') + return Buffer.concat([header, body]) +} + +/** + * A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any + * whole message bodies that completed. It parses only the `Content-Length` header and ignores other + * headers (e.g. `Content-Type`), matching the base protocol. + */ +export class MessageDecoder { + private buffer: Buffer = Buffer.alloc(0) + private readonly maxMessageBytes: number + + /** + * @param maxMessageBytes - reject any single framed body larger than this (guards memory). + */ + constructor(maxMessageBytes: number) { + this.maxMessageBytes = maxMessageBytes + } + + /** + * Append a chunk and return every message body that is now complete. + * @param chunk - raw bytes from the server's stdout. + * @returns the parsed JSON bodies, in arrival order (possibly empty). + * @throws Error when a header is malformed or a body exceeds `maxMessageBytes`. + */ + push(chunk: Buffer): unknown[] { + this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]) + const messages: unknown[] = [] + for (;;) { + const step = this.next() + if (!step.ready) break + messages.push(step.message) + } + return messages + } + + /** Parse and consume the next complete message, or report that more bytes are needed. */ + private next(): { ready: false } | { ready: true; message: unknown } { + const separator = this.buffer.indexOf(HEADER_SEPARATOR) + if (separator < 0) { + if (this.buffer.length > MAX_HEADER_BYTES) { + throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`) + } + return { ready: false } + } + const headerText = this.buffer.toString('ascii', 0, separator) + const contentLength = parseContentLength(headerText) + if (contentLength > this.maxMessageBytes) { + throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`) + } + const bodyStart = separator + HEADER_SEPARATOR.length + const bodyEnd = bodyStart + contentLength + if (this.buffer.length < bodyEnd) return { ready: false } + const body = this.buffer.toString('utf8', bodyStart, bodyEnd) + this.buffer = this.buffer.subarray(bodyEnd) + try { + return { ready: true, message: JSON.parse(body) } + } catch (error) { + /* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */ + throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`) + } + } +} + +/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */ +function parseContentLength(headerText: string): number { + for (const line of headerText.split('\r\n')) { + const colon = line.indexOf(':') + if (colon < 0) continue + if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue + const value = Number(line.slice(colon + 1).trim()) + if (!Number.isInteger(value) || value < 0) { + throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`) + } + return value + } + throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`) +} diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts new file mode 100644 index 0000000000..a90a703d96 --- /dev/null +++ b/packages/lsp/lsp-local/src/host.ts @@ -0,0 +1,104 @@ +/** + * Host-filesystem source access for the local provider, using Node APIs directly in the + * subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not + * satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target + * identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server + * startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the + * workspace. External result locations are allowed, but an external path can never become a query + * source. + * @module @deepseek-ai/dsh-lsp-local/host + */ + +import { readFile, realpath, stat } from 'node:fs/promises' +import { isAbsolute, resolve as resolvePath, sep } from 'node:path' + +/** A validated source: its canonical absolute path and current UTF-8 text. */ +export interface HostSource { + /** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */ + readonly canonicalPath: string + /** The file's current text, read as UTF-8. */ + readonly text: string +} + +/** + * Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies + * process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots + * collapse to one instance. + * @param workspaceRoot - the caller's workspace root (absolute). + * @returns the canonical directory path. + * @throws Error when the path is missing or not a directory. + */ +export async function canonicalizeWorkspace(workspaceRoot: string): Promise { + let canonical: string + try { + canonical = await realpath(workspaceRoot) + } catch (error) { + throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`) + } + const info = await stat(canonical) + if (!info.isDirectory()) { + throw new Error(`workspace root "${workspaceRoot}" is not a directory`) + } + return canonical +} + +/** + * Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath` + * resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target + * must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical + * workspace. + * @param filePath - the model-supplied source path (relative or absolute). + * @param canonicalWorkspace - the already-canonicalized workspace root. + * @param maxDocumentBytes - the largest source this host will open. + * @returns the canonical path and current UTF-8 text. + * @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace. + */ +export async function readHostSource( + filePath: string, + canonicalWorkspace: string, + maxDocumentBytes: number, +): Promise { + const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath) + let canonicalPath: string + try { + canonicalPath = await realpath(requested) + } catch (error) { + throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`) + } + if (!isInside(canonicalWorkspace, canonicalPath)) { + throw new Error(`source "${filePath}" resolves outside the workspace`) + } + const info = await stat(canonicalPath) + if (!info.isFile()) { + throw new Error(`source "${filePath}" is not a regular file`) + } + if (info.size > maxDocumentBytes) { + throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) + } + const buffer = await readFile(canonicalPath) + const text = decodeUtf8Strict(buffer, filePath) + return { canonicalPath, text } +} + +/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */ +function isInside(workspace: string, child: string): boolean { + if (child === workspace) return true + /* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */ + const base = workspace.endsWith(sep) ? workspace : workspace + sep + return child.startsWith(base) +} + +/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */ +function decodeUtf8Strict(buffer: Buffer, filePath: string): string { + const text = buffer.toString('utf8') + if (text.includes('�')) { + throw new Error(`source "${filePath}" is not valid UTF-8 text`) + } + return text +} + +/** Extract a message from an unknown thrown value without leaking `any`. */ +function messageOf(error: unknown): string { + /* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */ + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts new file mode 100644 index 0000000000..b9c32867da --- /dev/null +++ b/packages/lsp/lsp-local/src/index.ts @@ -0,0 +1,255 @@ +/** + * Generic stdio language-server provider for `ctx.lsp`. One plugin instance configures one server + * command and its extension→language-id map; load multiple instances for multiple servers. The + * provider lazily single-flights one server process per `(provider id, canonical workspace + * realpath)`, serves transient-open queries through it, and evicts a crashed process so a later + * query can replace it. It reads sources through Node APIs in the host namespace (not `ctx.fs`) and + * trusts its configured server — no sandbox confinement. + * + * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal + * unregisters from `ctx.lsp` and tears down every live server. + * @module @deepseek-ai/dsh-lsp-local + */ + +import { accessSync, constants } from 'node:fs' +import { delimiter, isAbsolute, join } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { LspProviderId } from '@deepseek-ai/dsh-lsp' +import type { + LspProvider, + LspProviderQuery, + LspQueryResult, +} from '@deepseek-ai/dsh-lsp' +// Side-effect type import: declaration-merges `ctx.lsp` onto Context. +import type {} from '@deepseek-ai/dsh-lsp' +import { canonicalizeWorkspace } from './host.ts' +import { LspInstance } from './instance.ts' +import type { InstanceSpec } from './instance.ts' + +export { canonicalizeWorkspace, readHostSource } from './host.ts' +export { encodeMessage, MessageDecoder } from './framing.ts' +export { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from './translate.ts' +export { LspInstance } from './instance.ts' +export { LspConnection } from './connection.ts' + +/** Cordis plugin name for loader diagnostics. */ +export const name = 'lsp-local' + +/** Services required by this plugin. */ +export const inject = ['lsp'] + +/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */ +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000 +const DEFAULT_MAX_STDERR_BYTES = 1_000_000 +const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000 +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000 +const DEFAULT_KILL_GRACE_MS = 2_000 + +/** Plugin configuration: one server command plus its extension mapping and host bounds. */ +export interface Config { + /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ + providerId: string + /** Executable to spawn (absolute, or resolved on PATH at load). */ + command: string + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + extensionToLanguage: Record + /** Arguments passed to the executable (no shell). Default `[]`. */ + args?: string[] + /** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */ + env?: Record + /** Static `initialize` options forwarded to the server. Default `null`. */ + initializationOptions?: unknown + /** Static answer to every `workspace/configuration` item. Default `null`. */ + configuration?: unknown + /** Largest single framed message accepted from the server (bytes). Default 16000000. */ + maxMessageBytes?: number + /** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */ + maxStderrBytes?: number + /** Largest source file this host will open (bytes). Default 4000000. */ + maxDocumentBytes?: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + shutdownTimeoutMs?: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + killGraceMs?: number +} + +/** The resolved config after schemastery fills every default; the provider reads this shape. */ +type ResolvedConfig = Required + +export const Config: z = z.object({ + providerId: z.string().required(), + command: z.string().required(), + args: z.array(String).default([]), + env: z.dict(String).default({}), + extensionToLanguage: z.dict(String).required(), + initializationOptions: z.any().default(null), + configuration: z.any().default(null), + maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES), + maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES), + maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES), + shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS), + killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS), +}) + +/** + * Register a generic stdio LSP provider. Resolves the executable at load (after credential + * scrubbing) and fails before registration when it is unavailable; the process itself launches + * lazily on the first matching query. + * @param ctx - the plugin context (must inject `lsp`). + * @param config - the resolved plugin configuration (schemastery has filled every default). + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + const childEnv = buildChildEnv(resolved.env) + // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. + const executable = resolveExecutable(resolved.command, childEnv) + + const provider = new LocalLspProvider(resolved, childEnv, executable) + ctx.effect(() => { + const dispose = ctx.lsp.registerProvider(provider) + return async () => { + dispose() + await provider.disposeAll() + } + }, 'lsp-local.registerProvider') +} + +/** A pooled generic provider: one server process per canonical workspace, created on demand. */ +class LocalLspProvider implements LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + /** Single-flight map: canonical workspace realpath → the (pending) instance for it. */ + private readonly instances = new Map>() + private disposed = false + + constructor( + private readonly config: ResolvedConfig, + private readonly childEnv: Record, + private readonly executable: string, + ) { + this.id = LspProviderId(config.providerId) + this.extensionToLanguage = config.extensionToLanguage + } + + async query(request: LspProviderQuery, signal?: AbortSignal): Promise { + /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches a disposed provider; defensive. */ + if (this.disposed) throw new Error('lsp-local provider is disposed') + const workspace = await canonicalizeWorkspace(request.workspaceRoot) + const instance = await this.instanceFor(workspace) + try { + return await instance.query(request, signal) + } finally { + // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, + // but only if the slot still holds THIS instance (a concurrent replacement must survive). + if (instance.dead) { + const slot = this.instances.get(workspace) + /* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */ + if (slot !== undefined && (await settledInstance(slot)) === instance) { + this.instances.delete(workspace) + } + } + } + } + + /** Single-flight one instance per canonical workspace; a rejected creation clears the slot. */ + private instanceFor(workspace: string): Promise { + const existing = this.instances.get(workspace) + if (existing !== undefined) return existing + const created = Promise.resolve().then(() => this.createInstance(workspace)) + this.instances.set(workspace, created) + /* v8 ignore next 3 -- createInstance (the LspInstance constructor) does not throw; spawn failures + surface asynchronously through the instance, so this creation-rejection cleanup is defensive. */ + created.catch(() => { + if (this.instances.get(workspace) === created) this.instances.delete(workspace) + }) + return created + } + + private createInstance(workspace: string): LspInstance { + const spec: InstanceSpec = { + command: this.executable, + args: this.config.args, + cwd: workspace, + env: this.childEnv, + configuration: this.config.configuration, + initializationOptions: this.config.initializationOptions, + maxMessageBytes: this.config.maxMessageBytes, + maxStderrBytes: this.config.maxStderrBytes, + maxDocumentBytes: this.config.maxDocumentBytes, + shutdownTimeoutMs: this.config.shutdownTimeoutMs, + killGraceMs: this.config.killGraceMs, + } + return new LspInstance(spec) + } + + /** Dispose every live instance and block further queries. */ + async disposeAll(): Promise { + this.disposed = true + const pending = [...this.instances.values()] + this.instances.clear() + await Promise.all(pending.map(async (entry) => { + try { + const instance = await entry + await instance.dispose() + } catch { + // A never-initialized instance already rejected; nothing to tear down. + } + })) + } +} + +/** Resolve a slot promise to its instance for identity comparison, tolerating a pending rejection. */ +async function settledInstance(slot: Promise): Promise { + try { + return await slot + } catch { + /* v8 ignore next -- a slot promise only rejects if createInstance throws, which it never does; defensive. */ + return undefined + } +} + +/** The ambient env minus credential-shaped vars, plus the config's explicit env. */ +function buildChildEnv(extra: Record): Record { + const scrubbed = Object.entries(process.env).filter( + ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key), + ) as [string, string][] + return { ...Object.fromEntries(scrubbed), ...extra } +} + +/** + * Resolve the server executable to an absolute path: an absolute command is verified directly; a + * bare command is looked up on the child's PATH. Fails loudly when nothing is executable. + */ +function resolveExecutable(command: string, childEnv: Record): string { + if (isAbsolute(command)) { + return command + } + /* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */ + const pathValue = childEnv.PATH ?? process.env.PATH ?? '' + for (const dir of pathValue.split(delimiter)) { + if (dir === '') continue + const candidate = join(dir, command) + if (isExecutableSync(candidate)) return candidate + } + throw new Error(`lsp-local: command "${command}" was not found on PATH`) +} + +/** Synchronous executable check used only at load-time resolution. */ +function isExecutableSync(path: string): boolean { + try { + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts new file mode 100644 index 0000000000..0e63e46d1e --- /dev/null +++ b/packages/lsp/lsp-local/src/instance.ts @@ -0,0 +1,293 @@ +/** + * One language-server instance: a connection plus the initialize handshake, the serialized abortable + * query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One + * instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single + * queue so a cancellation that fails to stop the server can terminate it without killing unrelated + * work; distinct instances run in parallel. + * @module @deepseek-ai/dsh-lsp-local/instance + */ + +import { pathToFileURL } from 'node:url' +import type { + LspOperation, + LspProviderQuery, + LspQueryResult, +} from '@deepseek-ai/dsh-lsp' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { LspConnection } from './connection.ts' +import type { ConnectionSpec } from './connection.ts' +import { readHostSource } from './host.ts' +import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' +import { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from './translate.ts' + +/** Everything an instance needs beyond the connection spec. */ +export interface InstanceSpec extends ConnectionSpec { + /** Static `initialize` options forwarded to the server. */ + readonly initializationOptions: unknown + /** Largest source file this host will open (bytes). */ + readonly maxDocumentBytes: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). */ + readonly shutdownTimeoutMs: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ + readonly killGraceMs: number +} + +/** + * A single initialized server process. Not exported as a provider — the provider single-flights and + * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down. + */ +export class LspInstance { + private readonly connection: LspConnection + private capabilities: WireServerCapabilities | undefined + /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ + private queue: Promise = Promise.resolve() + private disposed = false + /** Set once the process closes, so the pool can synchronously skip a dead instance. */ + private processClosed = false + /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ + private readonly ready: Promise + + /** + * @param spec - the launch, initialize, and teardown parameters. + */ + constructor(private readonly spec: InstanceSpec) { + this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params)) + this.ready = this.initialize() + // A handshake rejection must not surface as an unhandled rejection before the first query awaits + // it; queries attach the real handler. + this.ready.catch(() => {}) + void this.connection.closed.then(() => { this.processClosed = true }) + } + + /** Synchronous liveness check: true once the process has closed or the instance was disposed. */ + get dead(): boolean { + return this.processClosed || this.disposed + } + + /** + * Run one query through the serialized queue. + * @param request - the resolved provider query. + * @param signal - optional cancellation for this query's full lifecycle. + * @returns the normalized result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise { + const run = this.queue.then(() => this.runQuery(request, signal)) + // Keep the tail alive regardless of this query's outcome so the next caller still serializes. + this.queue = run.then(() => undefined, () => undefined) + return run + } + + private async initialize(): Promise { + const initializeResult = await this.connection.request('initialize', { + processId: process.pid, + rootUri: pathToFileURL(this.spec.cwd).href, + workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }], + capabilities: CLIENT_CAPABILITIES, + initializationOptions: this.spec.initializationOptions, + }) as WireInitializeResult + const capabilities = initializeResult.capabilities + // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. + negotiatePositionEncoding(capabilities.positionEncoding) + this.capabilities = capabilities + this.connection.notify('initialized', {}) + } + + private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise { + if (this.disposed) throw new Error('LSP instance was disposed') + if (signal?.aborted) throw abortError(signal) + await this.ready + const capabilities = this.capabilities + /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ + if (capabilities === undefined) throw new Error('LSP instance is not initialized') + if (!supportsOperation(capabilities, request.operation)) { + throw new Error(`server does not support ${request.operation}`) + } + if (!supportsTransientOpen(capabilities.textDocumentSync)) { + throw new Error('server does not support the transient textDocument/didOpen this host requires') + } + + const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes) + const uri = pathToFileURL(source.canonicalPath).href + let opened = false + try { + if (signal?.aborted) throw abortError(signal) + this.connection.notify('textDocument/didOpen', { + textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, + }) + opened = true + const payload = await this.sendRequest(request.operation, uri, request.position, signal) + return this.normalize(request.operation, payload) + } finally { + if (opened) { + try { + this.connection.notify('textDocument/didClose', { textDocument: { uri } }) + } catch (error) { + /* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error' + listener, so a synchronous didClose write failure is a defensive path. */ + // A close-write failure does not replace the settled result/error, but the instance can no + // longer be trusted: invalidate it and await bounded process termination. + this.disposed = true + void this.tearDown(error instanceof Error ? error : new Error(String(error))) + /* v8 ignore stop */ + } + } + } + } + + private async sendRequest( + operation: LspOperation, + uri: string, + position: LspProviderQuery['position'], + signal?: AbortSignal, + ): Promise { + const params = { + textDocument: { uri }, + position: { line: position.line, character: position.character }, + // references always includes declarations: the caller gets no flag and impact analysis never + // omits the defining site. + ...(operation === 'references' ? { context: { includeDeclaration: true } } : {}), + } + const requestId = this.connection.peekNextId() + const send = this.connection.request(requestMethod(operation), params) + if (signal === undefined) return send + return this.raceAbort(send, requestId, signal) + } + + /** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */ + private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { + const abort = new Promise((_, reject) => { + const onAbort = (): void => { reject(abortError(signal)) } + /* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */ + if (signal.aborted) { onAbort(); return } + signal.addEventListener('abort', onAbort, { once: true }) + // Remove the abort listener once the request settles either way; the finally-promise inherits + // send's rejection, so catch it to avoid an unhandled rejection when abort already won. + send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {}) + }) + try { + return await Promise.race([send, abort]) + } catch (error) { + if (signal.aborted) this.connection.cancel(requestId) + throw error + } + } + + private normalize(operation: LspOperation, payload: unknown): LspQueryResult { + if (operation === 'hover') { + return { kind: 'hover', hover: normalizeHover(payload) } + } + return { kind: 'locations', locations: normalizeLocations(payload) } + } + + private answerServerRequest(method: string, params: unknown): Promise { + if (method === 'workspace/configuration') { + // Answer every requested item with the one static configuration value. + const record = params as { items?: unknown[] } | null + /* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */ + const items = Array.isArray(record?.items) ? record.items : [] + return Promise.resolve(items.map(() => this.spec.configuration)) + } + if (LIFECYCLE_NOOP_METHODS.has(method)) { + // Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic. + return Promise.resolve(null) + } + if (method === 'workspace/applyEdit') { + // This host never applies edits or runs commands. + return Promise.reject(new Error('workspace/applyEdit is not permitted by this host')) + } + return Promise.reject(new Error(`unsupported server request: ${method}`)) + } + + /** + * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting + * process close so nothing outlives disposal. + */ + async dispose(): Promise { + if (this.disposed) { + await this.connection.closed + return + } + this.disposed = true + await this.tearDown(new Error('LSP instance disposed')) + } + + private async tearDown(_reason: Error): Promise { + try { + using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') + await this.gracefulShutdown(shutdownDeadline.signal) + } catch { + // Graceful shutdown failed or timed out: fall through to signal escalation. + } + await this.forceTerminate() + } + + /** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */ + private async gracefulShutdown(signal: AbortSignal): Promise { + const shutdown = this.connection.request('shutdown', null) + await Promise.race([ + shutdown, + new Promise((_, reject) => { + /* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */ + if (signal.aborted) { reject(abortError(signal)); return } + signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true }) + }), + ]) + this.connection.notify('exit', null) + } + + /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */ + private async forceTerminate(): Promise { + this.connection.terminate() + using graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') + const closedInTime = await Promise.race([ + this.connection.closed.then(() => true), + new Promise((resolve) => { + /* v8 ignore next -- the kill-grace deadline signal is freshly armed and not yet aborted here; defensive. */ + if (graceDeadline.signal.aborted) { resolve(false); return } + graceDeadline.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + ]) + if (!closedInTime) this.connection.kill() + await this.connection.closed + } +} + +/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */ +const LIFECYCLE_NOOP_METHODS = new Set([ + 'window/workDoneProgress/create', + 'client/registerCapability', + 'client/unregisterCapability', +]) + +/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */ +function abortError(signal: AbortSignal): Error { + const timeout = timeoutOf(signal) + if (timeout !== undefined) return timeout + const reason: unknown = signal.reason + if (reason instanceof Error) return reason + return new Error('LSP query aborted') +} + +/** + * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and + * configuration, markdown/plaintext hover, and link support for definition/implementation. No + * dynamic registration; the server's returned capabilities are authoritative. + */ +const CLIENT_CAPABILITIES = { + general: { positionEncodings: ['utf-16'] }, + workspace: { workspaceFolders: true, configuration: true }, + textDocument: { + synchronization: { dynamicRegistration: false }, + hover: { contentFormat: ['markdown', 'plaintext'] }, + definition: { linkSupport: true }, + implementation: { linkSupport: true }, + references: {}, + }, +} as const diff --git a/packages/lsp/lsp-local/src/protocol.ts b/packages/lsp/lsp-local/src/protocol.ts new file mode 100644 index 0000000000..abceb04d71 --- /dev/null +++ b/packages/lsp/lsp-local/src/protocol.ts @@ -0,0 +1,80 @@ +/** + * The subset of LSP wire types this generic host reads and writes: initialize capabilities, the four + * request results (`Location`, `LocationLink`, `Hover`), and the `textDocumentSync` shapes used to + * decide transient-open support. Types only. Fields absent from a real server payload stay optional; + * the translation layer normalizes them into the seam's closed unions. + * @module @deepseek-ai/dsh-lsp-local/protocol + */ + +/** A zero-based UTF-16 position on the wire (the protocol's `Position`). */ +export interface WirePosition { + readonly line: number + readonly character: number +} + +/** A wire range (`Range`). */ +export interface WireRange { + readonly start: WirePosition + readonly end: WirePosition +} + +/** A `Location`: a document URI plus a range. */ +export interface WireLocation { + readonly uri: string + readonly range: WireRange +} + +/** A `LocationLink`: the target uri plus the selection range to focus. */ +export interface WireLocationLink { + readonly targetUri: string + readonly targetSelectionRange: WireRange + readonly targetRange?: WireRange +} + +/** A `MarkupContent` hover body (`markdown` or `plaintext`). */ +export interface WireMarkupContent { + readonly kind: 'markdown' | 'plaintext' + readonly value: string +} + +/** A `MarkedString` object form (`{ language, value }`); the string form is a bare `string`. */ +export interface WireMarkedStringObject { + readonly language: string + readonly value: string +} + +/** One `MarkedString`: a raw string or a language-tagged code block. */ +export type WireMarkedString = string | WireMarkedStringObject + +/** A `Hover`: contents in any of the protocol's three encodings, plus an optional range. */ +export interface WireHover { + readonly contents: WireMarkupContent | WireMarkedString | readonly WireMarkedString[] + readonly range?: WireRange +} + +/** The legacy enum form of `textDocumentSync` (`0` None, `1` Full, `2` Incremental). */ +export type WireTextDocumentSyncKind = 0 | 1 | 2 + +/** The options form of `textDocumentSync` (`{ openClose, change }`). */ +export interface WireTextDocumentSyncOptions { + readonly openClose?: boolean + readonly change?: WireTextDocumentSyncKind +} + +/** A `ServerCapabilities.provider` slot: a boolean or an options object (both mean "supported"). */ +export type WireProviderCapability = boolean | Record | undefined + +/** The `ServerCapabilities` fields this host inspects. */ +export interface WireServerCapabilities { + readonly positionEncoding?: string + readonly textDocumentSync?: WireTextDocumentSyncKind | WireTextDocumentSyncOptions + readonly definitionProvider?: WireProviderCapability + readonly referencesProvider?: WireProviderCapability + readonly implementationProvider?: WireProviderCapability + readonly hoverProvider?: WireProviderCapability +} + +/** The `initialize` result envelope. */ +export interface WireInitializeResult { + readonly capabilities: WireServerCapabilities +} diff --git a/packages/lsp/lsp-local/src/translate.ts b/packages/lsp/lsp-local/src/translate.ts new file mode 100644 index 0000000000..a212d283b6 --- /dev/null +++ b/packages/lsp/lsp-local/src/translate.ts @@ -0,0 +1,210 @@ +/** + * Pure protocol translation for the local host: what the server's capabilities allow, and how its + * `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O + * or process state — every function here is a pure transform, which the fake-stdio tests pin exactly. + * @module @deepseek-ai/dsh-lsp-local/translate + */ + +import type { + LspHover, + LspLocation, + LspOperation, + LspRange, +} from '@deepseek-ai/dsh-lsp' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { + WireHover, + WireLocation, + WireLocationLink, + WireMarkedString, + WireProviderCapability, + WireRange, + WireServerCapabilities, + WireTextDocumentSyncKind, + WireTextDocumentSyncOptions, +} from './protocol.ts' + +/** + * The `textDocument/*` request method for each seam operation. + * @param operation - the seam operation to map. + * @returns the LSP request method name. + */ +export function requestMethod(operation: LspOperation): string { + switch (operation) { + case 'definition': return 'textDocument/definition' + case 'references': return 'textDocument/references' + case 'implementation': return 'textDocument/implementation' + case 'hover': return 'textDocument/hover' + /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ + default: return assertNever(operation, 'requestMethod') + } +} + +/** The `ServerCapabilities` provider field backing each operation. */ +function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability { + switch (operation) { + case 'definition': return capabilities.definitionProvider + case 'references': return capabilities.referencesProvider + case 'implementation': return capabilities.implementationProvider + case 'hover': return capabilities.hoverProvider + /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ + default: return assertNever(operation, 'capabilityValue') + } +} + +/** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */ +function supportsCapability(value: WireProviderCapability): boolean { + if (value === undefined) return false + if (typeof value === 'boolean') return value + return true +} + +/** + * Whether the server advertises the requested operation. + * @param capabilities - the server's `initialize` capabilities. + * @param operation - the seam operation to check. + * @returns true when the corresponding provider capability is present. + */ +export function supportsOperation(capabilities: WireServerCapabilities, operation: LspOperation): boolean { + return supportsCapability(capabilityValue(capabilities, operation)) +} + +/** + * Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on. + * @param sync - the server's advertised `textDocumentSync` capability. + * @returns true when transient open/close is supported. + */ +export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean { + if (sync === undefined) return false + if (typeof sync === 'number') return isOpenCloseKind(sync) + return sync.openClose === true || (sync.openClose === undefined && changeAllowsOpenClose(sync)) +} + +/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */ +function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean { + return kind === 1 || kind === 2 +} + +/** Options without an explicit `openClose` fall back to the legacy `change` enum's implication. */ +function changeAllowsOpenClose(sync: WireTextDocumentSyncOptions): boolean { + return sync.change !== undefined && isOpenCloseKind(sync.change) +} + +/** + * Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value + * other than `utf-16` is a protocol error this host does not support. + * @param encoding - the server's advertised `positionEncoding`, if any. + * @returns the string `'utf-16'`. + * @throws Error for any non-`utf-16` encoding. + */ +export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' { + if (encoding === undefined || encoding === 'utf-16') return 'utf-16' + throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`) +} + +/** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */ +function toRange(range: WireRange): LspRange { + return { + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + } +} + +/** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */ +function isLocationLink(value: Record): boolean { + return typeof value.targetUri === 'string' && isRange(value.targetSelectionRange) +} + +/** Whether a record is a `Location` (has string `uri` + a range). */ +function isLocation(value: Record): boolean { + return typeof value.uri === 'string' && isRange(value.range) +} + +/** Structural range guard used by both location shapes. */ +function isRange(value: unknown): value is WireRange { + if (value === null || typeof value !== 'object') return false + const range = value as Record + return isPosition(range.start) && isPosition(range.end) +} + +/** Structural position guard. */ +function isPosition(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false + const position = value as Record + return typeof position.line === 'number' && typeof position.character === 'number' +} + +/** + * Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's + * locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`. + * @param payload - the raw `textDocument/definition|references|implementation` result. + * @returns the normalized locations (empty for `null`/`[]`). + * @throws Error when an element is neither a `Location` nor a `LocationLink`. + */ +export function normalizeLocations(payload: unknown): LspLocation[] { + if (payload === null || payload === undefined) return [] + const elements = Array.isArray(payload) ? payload : [payload] + const locations: LspLocation[] = [] + for (const element of elements) { + if (element === null || typeof element !== 'object') { + throw new Error('LSP navigation result contained a non-object entry') + } + const record = element as Record + if (isLocationLink(record)) { + const link = record as unknown as WireLocationLink + locations.push({ uri: link.targetUri, range: toRange(link.targetSelectionRange) }) + } else if (isLocation(record)) { + const location = record as unknown as WireLocation + locations.push({ uri: location.uri, range: toRange(location.range) }) + } else { + throw new Error('LSP navigation result contained neither a Location nor a LocationLink') + } + } + return locations +} + +/** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */ +function renderMarkedString(value: WireMarkedString): string { + if (typeof value === 'string') return value + return `\`\`\`${value.language}\n${value.value}\n\`\`\`` +} + +/** + * Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string + * `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array + * joins its rendered parts with one blank line. `maxHoverChars` is NOT applied here — the tool caps. + * @param payload - the raw `textDocument/hover` result. + * @returns the normalized hover, or `null` when there is no content. + * @throws Error when the payload is a non-null, non-object, or structurally invalid hover. + */ +export function normalizeHover(payload: unknown): LspHover | null { + if (payload === null || payload === undefined) return null + if (typeof payload !== 'object') throw new Error('LSP hover result was not an object') + const hover = payload as unknown as WireHover + const contents = renderHoverContents(hover.contents) + if (contents === '') return null + const range = hover.range + return range !== undefined && isRange(range) ? { contents, range: toRange(range) } : { contents } +} + +/** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */ +function renderHoverContents(contents: unknown): string { + if (contents === null || contents === undefined) { + throw new Error('LSP hover result had no contents') + } + if (typeof contents === 'string') return contents + if (Array.isArray(contents)) { + return contents.map(renderMarkedString).join('\n\n') + } + if (typeof contents !== 'object') { + throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') + } + const record = contents as Record + if (record.kind === 'markdown' || record.kind === 'plaintext') { + return typeof record.value === 'string' ? record.value : '' + } + if (typeof record.language === 'string' && typeof record.value === 'string') { + return renderMarkedString({ language: record.language, value: record.value }) + } + throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') +} diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..0b33953ba1 --- /dev/null +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -0,0 +1,73 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +/** + * Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and + * `@deepseek-ai/dsh-lsp-local` by name through their exports maps, spawns the fixture server, runs + * one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising + * subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/` + * is absent; CI runs it after the build. + */ + +const pkgDir = fileURLToPath(new URL('..', import.meta.url)) +const seamLib = join(pkgDir, '../lsp/lib/index.js') +const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) + +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 + +beforeAll(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }) +}) + +describe.skipIf(!built)('built lib real load path (plain node)', () => { + it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => { + const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) + const script = ` + const { Context } = await import('cordis') + const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') + const LspLocal = await import('@deepseek-ai/dsh-lsp-local') + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + providerId: 'fake', + command: ${JSON.stringify(process.execPath)}, + args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], + env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, + extensionToLanguage: { '.ts': 'typescript' }, + }) + const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) + console.log(JSON.stringify(result)) + await ctx.fiber.dispose() + process.exit(0) + ` + const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + const exitCode = await new Promise(resolve => child.on('close', resolve)) + + expect(exitCode, `stderr:\n${stderr}`).toBe(0) + const lastLine = stdout.trim().split('\n').at(-1) ?? '' + const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] } + expect(result.kind).toBe('locations') + expect(result.locations).toHaveLength(1) + }, 60_000) +}) diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts new file mode 100644 index 0000000000..baf6fde05f --- /dev/null +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { fileURLToPath } from 'node:url' +import { LspConnection } from '@deepseek-ai/dsh-lsp-local' + +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)) + +/** A recorded server→client request the test's handler saw. */ +interface SeenRequest { method: string; params: unknown } + +let open: LspConnection[] = [] + +afterEach(async () => { + for (const conn of open) { + conn.kill() + await conn.closed + } + open = [] +}) + +/** Spawn the fixture as a raw connection, with a scripted server-request handler. */ +function connect( + env: Record, + onServerRequest: (method: string, params: unknown) => Promise = () => Promise.resolve(null), + seen?: SeenRequest[], +): LspConnection { + const conn = new LspConnection({ + command: process.execPath, + args: ['--import', tsxLoader, fixtureServer], + cwd: process.cwd(), + env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, + maxMessageBytes: 16_000_000, + maxStderrBytes: 100_000, + configuration: { setting: 42 }, + }, (method, params) => { + seen?.push({ method, params }) + return onServerRequest(method, params) + }) + open.push(conn) + return conn +} + +describe('LspConnection', () => { + it('completes an initialize request/response round-trip and exposes a pid', async () => { + const conn = connect({}) + const result = await conn.request('initialize', { capabilities: {} }) + expect(result).toMatchObject({ capabilities: { hoverProvider: true } }) + expect(conn.pid).toBeGreaterThan(0) + }) + + it('rejects a request when the server replies with an error', async () => { + const conn = connect({ LSP_FAKE_ERROR: '1' }) + await conn.request('initialize', { capabilities: {} }) + await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) + }) + + it('answers a server workspace/configuration request from static config', async () => { + const seen: SeenRequest[] = [] + const conn = connect( + { LSP_FAKE_ON_OPEN: 'configuration' }, + (method, params) => { + if (method === 'workspace/configuration') { + const items = (params as { items: unknown[] }).items + return Promise.resolve(items.map(() => ({ setting: 42 }))) + } + return Promise.resolve(null) + }, + seen, + ) + await conn.request('initialize', { capabilities: {} }) + conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await waitFor(() => seen.some(s => s.method === 'workspace/configuration')) + expect(seen[0]?.method).toBe('workspace/configuration') + }) + + it('drops a server→client notification without replying', async () => { + const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' }) + await conn.request('initialize', { capabilities: {} }) + conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + // No throw and the connection stays usable. + await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() + }) + + it('sends an error response when the server-request handler rejects', async () => { + const seen: SeenRequest[] = [] + const conn = connect( + { LSP_FAKE_ON_OPEN: 'applyEdit' }, + method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null), + seen, + ) + await conn.request('initialize', { capabilities: {} }) + conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit')) + // The connection remains healthy after emitting the error response. + await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() + }) + + it('fails all pending requests and kills the process on a framing error', async () => { + const conn = connect({ LSP_FAKE_GARBAGE: '1' }) + // The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a + // Content-Length header, so initialize still resolves. This exercises the decoder's resilience. + await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined() + }) + + it('rejects a new request issued after the process closes', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + conn.terminate() + await conn.closed + await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/) + }) + + it('cancel is a no-op-safe write after close', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + conn.terminate() + await conn.closed + expect(() => { conn.cancel(1) }).not.toThrow() + }) + + it('caps the retained stderr tail', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000) + }) +}) + +/** Spawn a raw connection running an inline node script as the "server". */ +function connectScript(script: string, maxStderrBytes = 100_000): LspConnection { + const conn = new LspConnection({ + command: process.execPath, + args: ['-e', script], + cwd: process.cwd(), + env: { ...process.env as Record }, + maxMessageBytes: 16_000_000, + maxStderrBytes, + configuration: null, + }, () => Promise.resolve(null)) + open.push(conn) + return conn +} + +describe('LspConnection edge behavior', () => { + it('fails a request when the command cannot be spawned', async () => { + const conn = new LspConnection({ + command: '/definitely/not/a/real/binary/xyz', + args: [], + cwd: process.cwd(), + env: {}, + maxMessageBytes: 1000, + maxStderrBytes: 1000, + configuration: null, + }, () => Promise.resolve(null)) + open.push(conn) + await expect(conn.request('initialize', {})).rejects.toThrow() + }) + + it('kills the process and fails pending requests on a framing error', async () => { + // Emit an invalid Content-Length header, corrupting the stream irrecoverably. + const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)') + await expect(conn.request('initialize', {})).rejects.toThrow() + }) + + it('ignores a framed non-object message', async () => { + // Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) + + it('drops a response for an unknown id', async () => { + // Emit a response for id 999 (never sent), then answer our real request. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) + + it('caps the retained stderr tail at maxStderrBytes across chunks', async () => { + // Write stderr repeatedly so a later chunk arrives after the cap is already reached. + const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100) + await waitFor(() => conn.stderrTail.length >= 100) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(conn.stderrTail.length).toBe(100) + }) + + it('rejects with a fallback message when the error response has no message string', async () => { + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/) + }) + + it('rejects a pending request when the process exits mid-flight', async () => { + // Never responds, then exits shortly: the pending request must reject on close. + const conn = connectScript('setTimeout(()=>process.exit(0), 100)') + await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) + }) + + it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { + // A frame with a string id and no method: not dispatchable; the client must ignore it and still + // answer our real request. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) +}) + +/** Poll a predicate until it holds or a deadline elapses. */ +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts new file mode 100644 index 0000000000..104b223794 --- /dev/null +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -0,0 +1,144 @@ +/** + * A scriptable fake LSP server over stdio for lsp-local tests. It speaks the real + * `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake, + * transient open/close, request mapping, and teardown — without a real language server. + * + * Behavior is driven by env vars so one file backs many scenarios: + * - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch). + * - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full). + * - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults. + * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request. + * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests). + * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). + * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). + * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of + * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr. + * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response. + * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply. + * + * Run: node --import tsx fixture-server.ts + */ + +const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' +const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 +const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {} +const hang = process.env.LSP_FAKE_HANG === '1' +const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' +const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' +const onOpen = process.env.LSP_FAKE_ON_OPEN +const errorReply = process.env.LSP_FAKE_ERROR === '1' +const garbage = process.env.LSP_FAKE_GARBAGE === '1' + +let serverRequestId = 10_000 +const pendingServerRequests = new Map() + +function resultFor(method: string): unknown { + switch (method) { + case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) + case 'textDocument/references': return envJson('LSP_FAKE_REFS', null) + case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null) + case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null) + default: return null + } +} + +function envJson(name: string, fallback: unknown): unknown { + const raw = process.env[name] + return raw === undefined ? fallback : JSON.parse(raw) +} + +let buffer = Buffer.alloc(0) +process.stdin.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + for (;;) { + const sep = buffer.indexOf('\r\n\r\n') + if (sep < 0) break + const header = buffer.toString('ascii', 0, sep) + const match = /content-length:\s*(\d+)/i.exec(header) + if (!match) { buffer = buffer.subarray(sep + 4); continue } + const length = Number(match[1]) + const start = sep + 4 + if (buffer.length < start + length) break + const body = buffer.toString('utf8', start, start + length) + buffer = buffer.subarray(start + length) + handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }) + } +}) + +function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void { + const { id, method } = message + // A frame with an id but no method is the client's REPLY to a server→client request; log it. + if (method === undefined && id !== undefined && pendingServerRequests.has(id)) { + const kind = pendingServerRequests.get(id) + pendingServerRequests.delete(id) + process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`) + return + } + if (method === 'initialize') { + if (garbage) process.stdout.write('this is not a framed message\r\n') + send({ + id, + result: { + capabilities: { + positionEncoding: enc, + textDocumentSync: sync, + definitionProvider: true, + referencesProvider: true, + implementationProvider: true, + hoverProvider: true, + ...(extraCaps as Record), + }, + }, + }) + return + } + if (method === 'shutdown') { + if (noShutdown) return + send({ id, result: null }) + return + } + if (method === 'exit') { + process.exit(0) + } + if (method === 'textDocument/didOpen') { + if (crashOnOpen) process.exit(1) + if (onOpen !== undefined) emitServerRequest(onOpen) + return + } + if (method === 'textDocument/didClose' || method === 'initialized') return + if (method?.startsWith('textDocument/')) { + if (hang) return + if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return } + send({ id, result: resultFor(method) }) + return + } + // Unknown request with an id: answer null so the client never stalls. + if (id !== undefined) send({ id, result: null }) +} + +/** Emit a server→client request and log the client's reply to stderr for the test to assert. */ +function emitServerRequest(kind: string): void { + if (kind === 'notification') { + send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } }) + return + } + const id = serverRequestId++ + const method = kind === 'configuration' + ? 'workspace/configuration' + : kind === 'applyEdit' + ? 'workspace/applyEdit' + : kind === 'lifecycle' + ? 'client/registerCapability' + : 'window/showMessageRequest' + const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {} + pendingServerRequests.set(id, method) + send({ id, method, params }) +} + +function send(message: Record): void { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8') + process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body])) +} + +// Keep the event loop alive. +process.stdin.resume() diff --git a/packages/lsp/lsp-local/tests/framing.spec.ts b/packages/lsp/lsp-local/tests/framing.spec.ts new file mode 100644 index 0000000000..66bca07f10 --- /dev/null +++ b/packages/lsp/lsp-local/tests/framing.spec.ts @@ -0,0 +1,76 @@ +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/) + }) +}) diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts new file mode 100644 index 0000000000..b76aa556e2 --- /dev/null +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { realpath } from 'node:fs/promises' +import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local' + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-'))) + ws = join(root, 'ws') + await mkdir(ws) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const BIG = 1_000_000 + +describe('canonicalizeWorkspace', () => { + it('returns the realpath of a directory', async () => { + expect(await canonicalizeWorkspace(ws)).toBe(ws) + }) + + it('resolves a symlinked workspace to its target so aliases share identity', async () => { + const link = join(root, 'ws-link') + await symlink(ws, link) + expect(await canonicalizeWorkspace(link)).toBe(ws) + }) + + it('rejects a missing workspace', async () => { + await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/) + }) + + it('rejects a non-directory workspace', async () => { + const file = join(root, 'file.txt') + await writeFile(file, 'x') + await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/) + }) +}) + +describe('readHostSource', () => { + it('reads a relative path against the workspace', async () => { + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') + const source = await readHostSource('a.ts', ws, BIG) + expect(source.canonicalPath).toBe(join(ws, 'a.ts')) + expect(source.text).toBe('const x = 1\n') + }) + + it('reads an absolute path inside the workspace', async () => { + const abs = join(ws, 'b.ts') + await writeFile(abs, 'b') + const source = await readHostSource(abs, ws, BIG) + expect(source.canonicalPath).toBe(abs) + }) + + it('accepts a source reached through a symlink that stays inside the workspace', async () => { + await mkdir(join(ws, 'real')) + await writeFile(join(ws, 'real', 'c.ts'), 'c') + await symlink(join(ws, 'real'), join(ws, 'linked')) + const source = await readHostSource('linked/c.ts', ws, BIG) + expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts')) + }) + + it('rejects a source whose canonical path escapes the workspace via symlink', async () => { + const outside = join(root, 'outside.ts') + await writeFile(outside, 'secret') + await symlink(outside, join(ws, 'escape.ts')) + await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/) + }) + + it('rejects an absolute source outside the workspace', async () => { + const outside = join(root, 'out.ts') + await writeFile(outside, 'x') + await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/) + }) + + it('rejects a missing source', async () => { + await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/) + }) + + it('rejects a non-regular source (directory)', async () => { + await mkdir(join(ws, 'dir')) + await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/) + }) + + it('treats the workspace root itself as inside, then rejects it as non-regular', async () => { + // filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the + // directory then fails the regular-file check. + await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/) + }) + + it('rejects an oversized source', async () => { + await writeFile(join(ws, 'big.ts'), 'x'.repeat(100)) + await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/) + }) + + it('rejects a non-UTF-8 source', async () => { + await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00])) + await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/) + }) +}) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts new file mode 100644 index 0000000000..da3231f133 --- /dev/null +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -0,0 +1,184 @@ +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 = {}, overrides: Partial = {}): LspInstance { + const instance = new LspInstance({ + command: process.execPath, + args: ['--import', tsxLoader, fixtureServer], + cwd: ws, + env: { ...process.env as Record, 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 = {}): LspInstance { + const instance = new LspInstance({ + command: process.execPath, + args: ['-e', script], + cwd: ws, + env: { ...process.env as Record }, + 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 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(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(resolve => setTimeout(resolve, 200)) + controller.abort('a string reason, not an Error') + await expect(pending).rejects.toThrow(/aborted/) + }) +}) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts new file mode 100644 index 0000000000..c5631f2a95 --- /dev/null +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { deadline } from '@deepseek-ai/dsh-timeout' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import type { Config } from '@deepseek-ai/dsh-lsp-local' + +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 + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */ +async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + providerId: 'fake', + command: process.execPath, + args: ['--import', tsxLoader, fixtureServer], + env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv }, + extensionToLanguage: { '.ts': 'typescript' }, + ...overrides, + }) + return ctx +} + +function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest { + return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws } +} + +/** A single Location JSON pointing into the workspace. */ +function locationJson(line: number): unknown { + return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } } +} + +describe('lsp-local end to end over a fake server', () => { + it('resolves definition to normalized locations', async () => { + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const result = await ctx.lsp.query(query('definition')) + expect(result).toEqual({ + kind: 'locations', + locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], + }) + await ctx.fiber.dispose() + }) + + it('maps a LocationLink for implementation', async () => { + const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } } + const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) }) + const result = await ctx.lsp.query(query('implementation')) + expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] }) + await ctx.fiber.dispose() + }) + + it('returns references (server includes the declaration)', async () => { + const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) }) + const result = await ctx.lsp.query(query('references')) + expect(result).toMatchObject({ kind: 'locations' }) + if (result.kind !== 'locations') throw new Error('expected locations') + expect(result.locations).toHaveLength(2) + await ctx.fiber.dispose() + }) + + it('normalizes a hover MarkupContent', async () => { + const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) }) + const result = await ctx.lsp.query(query('hover')) + expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } }) + await ctx.fiber.dispose() + }) + + it('returns an empty locations result for a null definition', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + await ctx.fiber.dispose() + }) + + it('returns a null hover for a null result', async () => { + const ctx = await mount({ LSP_FAKE_HOVER: 'null' }) + expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null }) + await ctx.fiber.dispose() + }) + + it('rejects a non-utf-16 position encoding at initialize', async () => { + const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await ctx.fiber.dispose() + }) + + it('rejects a server without transient-open sync (None)', async () => { + const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/) + await ctx.fiber.dispose() + }) + + it('accepts openClose options sync', async () => { + const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + await ctx.fiber.dispose() + }) + + it('fails a query for an unsupported operation', async () => { + const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/) + await ctx.fiber.dispose() + }) + + it('rejects a source outside the workspace before startup', async () => { + const outside = join(root, 'out.ts') + await writeFile(outside, 'x') + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/) + await ctx.fiber.dispose() + }) + + it('serializes queries through one instance and runs them in order', async () => { + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const results = await Promise.all([ + ctx.lsp.query(query('definition')), + ctx.lsp.query(query('definition')), + ctx.lsp.query(query('definition')), + ]) + for (const result of results) expect(result).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('aborts an in-flight query when the signal fires', async () => { + const ctx = await mount({ LSP_FAKE_HANG: '1' }) + const controller = new AbortController() + const pending = ctx.lsp.query(query('definition'), controller.signal) + controller.abort(new Error('caller cancelled')) + await expect(pending).rejects.toThrow(/cancelled/) + await ctx.fiber.dispose() + }) + + it('classifies a timeout deadline as the abort reason', async () => { + const ctx = await mount({ LSP_FAKE_HANG: '1' }) + using d = deadline(undefined, 50, 'TEST_TIMEOUT') + await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/) + await ctx.fiber.dispose() + }) + + it('fails the active query when the server crashes on open, and replaces it next query', async () => { + const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang). + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + await ctx.fiber.dispose() + }) + + it('runs distinct workspaces in parallel instances', async () => { + const ws2 = join(root, 'ws2') + await mkdir(ws2) + await writeFile(join(ws2, 'a.ts'), 'const z = 2\n') + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const [r1, r2] = await Promise.all([ + ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }), + ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }), + ]) + expect(r1).toMatchObject({ kind: 'locations' }) + expect(r2).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('disposes cleanly, terminating a server that ignores shutdown', async () => { + const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 }) + await ctx.lsp.query(query('definition')) + await expect(ctx.fiber.dispose()).resolves.toBeUndefined() + }) + + it('rejects at load when the command is not found', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'missing', + command: 'definitely-not-a-real-lsp-binary-xyz', + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/was not found on PATH/) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts new file mode 100644 index 0000000000..5d4045f507 --- /dev/null +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-'))) + 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 }) +}) + +function query(): LspQueryRequest { + return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } +} + +describe('lsp-local provider resolution', () => { + it('resolves a bare command on the child PATH and registers the provider', async () => { + // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. + const bin = join(root, 'bin') + await mkdir(bin) + const exe = join(bin, 'fake-lsp') + await writeFile(exe, '#!/bin/sh\nexit 0\n') + await chmod(exe, 0o755) + + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'onpath', + command: 'fake-lsp', + args: [], + env: { PATH: bin }, + extensionToLanguage: { '.ts': 'typescript' }, + })).resolves.toBeDefined() + await ctx.fiber.dispose() + }) + + it('skips empty PATH segments and fails when the command is absent', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'nope', + command: 'fake-lsp', + args: [], + env: { PATH: `::${join(root, 'empty')}` }, + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/was not found on PATH/) + await ctx.fiber.dispose() + }) + + it('rejects a query after the provider is disposed', async () => { + // Use a server that never emits results and dispose the plugin, then confirm queries are refused. + const ctx = new Context() + await ctx.plugin(Lsp) + // Grab the provider instance by registering, then dispose the whole plugin fiber. + const lsp = ctx.lsp + const fiber = await ctx.plugin(LspLocal, { + providerId: 'disp', + command: process.execPath, + args: ['-e', 'setInterval(()=>{},1000)'], + extensionToLanguage: { '.ts': 'typescript' }, + }) + await fiber.dispose() + // After disposal the provider unregistered from the seam, so selection fails as unavailable. + await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/lsp/lsp-local/tests/translate.spec.ts b/packages/lsp/lsp-local/tests/translate.spec.ts new file mode 100644 index 0000000000..2c339075e5 --- /dev/null +++ b/packages/lsp/lsp-local/tests/translate.spec.ts @@ -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' }) + }) +}) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts new file mode 100644 index 0000000000..ca4df620d3 --- /dev/null +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -0,0 +1,111 @@ +/** + * Keyless real-server e2e: drives the real `typescript-language-server` through the full + * `ctx.lsp` → `dsh-lsp-local` stack over the base protocol, exercising all four operations. No API + * key needed — the server is a local dev dependency. This establishes one compatibility floor + * (TypeScript), not a cross-language claim. + */ + +import { afterAll, beforeAll, 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 { Context } from 'cordis' +import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' + +// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path. +const serverBin = join( + new URL('..', import.meta.url).pathname, + 'node_modules', + '.bin', + 'typescript-language-server', +) + +let root: string +let ws: string +let ctx: Context + +beforeAll(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-'))) + ws = join(root, 'proj') + await mkdir(ws) + await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } })) + // A small program with a definition, a reference, an interface + implementation, and a typed value. + await writeFile(join(ws, 'shapes.ts'), [ + 'export interface Shape {', + ' area(): number', + '}', + '', + 'export class Circle implements Shape {', + ' constructor(private r: number) {}', + ' area(): number { return Math.PI * this.r * this.r }', + '}', + '', + 'export function describe(s: Shape): string {', + ' return `area=${s.area()}`', + '}', + '', + 'const c = new Circle(2)', + 'export const text = describe(c)', + '', + ].join('\n')) + + ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + providerId: 'typescript', + command: serverBin, + args: ['--stdio'], + extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + }) +}, 60_000) + +afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + if (root) await rm(root, { recursive: true, force: true }) +}) + +/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */ +function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest { + return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws } +} + +function locations(result: LspQueryResult): readonly { uri: string }[] { + if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`) + return result.locations +} + +describe('real typescript-language-server', () => { + it('resolves the definition of a call site to its declaration', async () => { + // `export const text = describe(c)` (line 15): `describe` begins at column 21. + const result = await ctx.lsp.query(at('definition', 15, 22)) + const locs = locations(result) + expect(locs.length).toBeGreaterThanOrEqual(1) + expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true) + }, 60_000) + + it('finds references to a symbol including its declaration', async () => { + // References to `describe` from its declaration (line 10, col 17). + const result = await ctx.lsp.query(at('references', 10, 17)) + const locs = locations(result) + // At least the declaration plus the call site. + expect(locs.length).toBeGreaterThanOrEqual(2) + }, 60_000) + + it('resolves implementations of an interface', async () => { + // Implementations of `Shape` (line 1, col 18) → Circle. + const result = await ctx.lsp.query(at('implementation', 1, 18)) + const locs = locations(result) + expect(locs.length).toBeGreaterThanOrEqual(1) + }, 60_000) + + it('returns hover information for a typed symbol', async () => { + // Hover on `Circle` in `new Circle(2)` (line 14, col 15). + const result = await ctx.lsp.query(at('hover', 14, 15)) + expect(result.kind).toBe('hover') + if (result.kind === 'hover') { + expect(result.hover).not.toBeNull() + expect(result.hover?.contents).toContain('Circle') + } + }, 60_000) +}) diff --git a/packages/lsp/lsp-local/tsconfig.json b/packages/lsp/lsp-local/tsconfig.json new file mode 100644 index 0000000000..281a8cebbf --- /dev/null +++ b/packages/lsp/lsp-local/tsconfig.json @@ -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": "../../util/brand" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../lsp" + } + ] +} diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md new file mode 100644 index 0000000000..eac45e9f51 --- /dev/null +++ b/packages/lsp/lsp/README.md @@ -0,0 +1,38 @@ +# @deepseek-ai/dsh-lsp + +The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses. + +This package is the interface third of the LSP capability: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy | +| `@deepseek-ai/dsh-lsp-local` | a generic stdio language-server provider | +| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | + +The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. + +## Service API (`ctx.lsp`) + +| Member | Semantics | +|---|---| +| `registerProvider(provider)` | Register a backend, atomically reserving its branded `id` and every normalized file extension. Any invalid input or conflict publishes nothing and throws `LspError` (`LSP_INVALID_PROVIDER` / `LSP_CONFLICT`). Returns a disposer releasing all reservations. Disposed with the calling fiber. | +| `query(request, signal?)` | Select the provider by the file's final extension, derive the `languageId` from that provider's mapping, and run one query. No match throws `LspError` `LSP_UNAVAILABLE`. | + +Selection is per query and order-independent: a provider owns a set of extensions exclusively, so registration and HMR order never change routing. Extension keys normalize to lowercase, leading-dot form; the `languageId` only synchronizes the transient document, never participates in selection. The first version has no glob, language-id, or explicit route selector. + +Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner of the model-facing name, description, prompt guidance, schema, and presentation. + +## Vocabulary + +`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. + +## Model Experience + +Indirectly, through `dsh-tool-lsp`, which owns the model-facing `lsp` schema, prompt, and rendered results while this registry contributes no prompt or schema itself. + +## Known Limitations and Deferred Work + +- **Exclusive extension ownership within one runtime** — two providers cannot both claim `.ts`, even with different language ids; overlaps fail registration. The intended extension is a deployment-configured selector above registrations, which can relax exclusive reservation without adding provider choice to model input ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **Four operations only** — symbols and call hierarchy are deferred (they need different schemas); diagnostics need separate freshness/accumulation rules; mutations (rename, code actions, formatting) require separate tools with preview, permission, and write-policy integration. +- **No observation surface** — availability is observed only by running `query()` and routing the thrown `LspError` codes; there is no provider-change event or capability-status query. diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json new file mode 100644 index 0000000000..04de5b2506 --- /dev/null +++ b/packages/lsp/lsp/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-lsp", + "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", + "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-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/lsp/lsp/src/brand.ts b/packages/lsp/lsp/src/brand.ts new file mode 100644 index 0000000000..fe51a1ea00 --- /dev/null +++ b/packages/lsp/lsp/src/brand.ts @@ -0,0 +1,21 @@ +/** + * dsh-lsp's owned branded id: {@link LspProviderId}, the opaque identity a provider reserves on + * `ctx.lsp`. The `Branded` primitive lives in `@deepseek-ai/dsh-brand`; keeping the type and its + * factory together here lets `index.ts` re-export both under one name. + * @module @deepseek-ai/dsh-lsp/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque provider identity, reserved atomically with its extension mappings at registration. */ +export type LspProviderId = Branded<'LspProviderId'> + +/** + * Brand a string as an {@link LspProviderId}. No validation — the registry rejects an empty id at + * registration. + * @param id - the provider's stable identifier. + * @returns the same string, branded. + */ +export function LspProviderId(id: string): LspProviderId { + return id as LspProviderId +} diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts new file mode 100644 index 0000000000..e00005acde --- /dev/null +++ b/packages/lsp/lsp/src/index.ts @@ -0,0 +1,156 @@ +/** + * The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query, + * order-independent selection over normalized definition/references/implementation/hover queries. + * + * A provider reserves a branded id and an exclusive set of file extensions atomically: + * {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an + * invalid or conflicting registration publishes nothing, and its disposer releases every + * reservation together. Selection routes a query by the file's final extension; it never depends on + * registration order. The seam exposes exactly the four operations and no JSON-RPC escape hatch. + * @module @deepseek-ai/dsh-lsp + */ + +import { Context, Service } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { LspProviderId } from './brand.ts' +import type { + LspProvider, + LspQueryRequest, + LspQueryResult, + LspService, +} from './types.ts' + +export { LspProviderId } from './brand.ts' +export type { + LspHover, + LspLocation, + LspOperation, + LspPosition, + LspProvider, + LspProviderQuery, + LspQueryRequest, + LspQueryResult, + LspRange, + LspService, +} from './types.ts' + +declare module 'cordis' { + interface Context { + lsp: LspService + } +} + +/** + * Structured LSP failure. Extends {@link HarnessError} with a stable `code` + * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`, …) that + * callers route on instead of parsing `message`. + */ +export class LspError extends HarnessError {} + +/** + * Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` → + * `.ts`, `foo.d.ts` → `.ts`). Returns `''` for a name with no extension or a leading-dot dotfile + * (`.bashrc`), which no route ever matches. Splits on both `/` and `\` so a caller's path separator + * does not change the result. + * @param filePath - the source path to inspect. + * @returns the normalized extension, or `''` when there is none. + */ +export function finalExtension(filePath: string): string { + const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath + const dot = base.lastIndexOf('.') + // dot <= 0 covers both "no dot" (-1) and a leading-dot dotfile (0): neither has an extension. + if (dot <= 0) return '' + return base.slice(dot).toLowerCase() +} + +/** A well-formed normalized extension: a dot followed by one or more non-dot, non-separator chars. */ +const EXTENSION_PATTERN = /^\.[^./\\]+$/ + +/** One selection route: the provider to run plus the language id to synchronize the document with. */ +interface Route { + readonly provider: LspProvider + readonly languageId: string +} + +/** + * `ctx.lsp`. Holds the id reservations and the extension→route table; both are populated and cleared + * together per provider so a route always has a live provider. + */ +export class Lsp extends Service implements LspService { + private readonly providerIds = new Set() + private readonly routes = new Map() + + constructor(ctx: Context) { + super(ctx, 'lsp') + } + + registerProvider(provider: LspProvider): () => void { + // Validate and conflict-check everything BEFORE any mutation: an invalid or conflicting + // registration must publish nothing (fail-loud, all-or-nothing). + const id = provider.id + if (id.trim() === '') { + throw new LspError('an LSP provider id must be a non-empty string', 'LSP_INVALID_PROVIDER') + } + if (this.providerIds.has(id)) { + throw new LspError(`an LSP provider with id "${id}" is already registered`, 'LSP_CONFLICT') + } + + const entries = Object.entries(provider.extensionToLanguage) + if (entries.length === 0) { + throw new LspError(`LSP provider "${id}" registers no file extensions`, 'LSP_INVALID_PROVIDER') + } + + // Normalize into this provider's route set, catching intra-provider duplicates (e.g. `.TS` and + // `.ts`) before checking cross-provider conflicts. + const pending = new Map() + for (const [rawExt, languageId] of entries) { + const ext = normalizeExtension(rawExt) + if (!EXTENSION_PATTERN.test(ext)) { + throw new LspError(`LSP provider "${id}" maps an invalid extension "${rawExt}"`, 'LSP_INVALID_PROVIDER') + } + if (languageId.trim() === '') { + throw new LspError(`LSP provider "${id}" maps extension "${ext}" to an empty language id`, 'LSP_INVALID_PROVIDER') + } + if (pending.has(ext)) { + throw new LspError(`LSP provider "${id}" maps extension "${ext}" more than once`, 'LSP_INVALID_PROVIDER') + } + pending.set(ext, { provider, languageId }) + } + for (const ext of pending.keys()) { + if (this.routes.has(ext)) { + throw new LspError(`extension "${ext}" is already handled by another LSP provider`, 'LSP_CONFLICT') + } + } + + // All checks passed: reserve id and every extension in one lifecycle controller so disposal + // releases them together. + const dispose = this.ctx.effect(function* (this: Lsp) { + this.providerIds.add(id) + for (const [ext, route] of pending) this.routes.set(ext, route) + yield () => { + this.providerIds.delete(id) + for (const ext of pending.keys()) this.routes.delete(ext) + } + }.bind(this), 'lsp.registerProvider()') + // ctx.effect's disposer returns Promise; our disposer API is synchronous + // fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + async query(request: LspQueryRequest, signal?: AbortSignal): Promise { + const route = this.routes.get(finalExtension(request.filePath)) + if (route === undefined) { + throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE') + } + return route.provider.query({ ...request, languageId: route.languageId }, signal) + } +} + +/** Lowercase an extension and ensure it carries a leading dot; `EXTENSION_PATTERN` rejects the rest. */ +function normalizeExtension(ext: string): string { + const lower = ext.toLowerCase() + return lower.startsWith('.') ? lower : `.${lower}` +} + +export default Lsp diff --git a/packages/lsp/lsp/src/types.ts b/packages/lsp/lsp/src/types.ts new file mode 100644 index 0000000000..0a2d73fac1 --- /dev/null +++ b/packages/lsp/lsp/src/types.ts @@ -0,0 +1,124 @@ +/** + * LSP seam vocabulary: the normalized request, provider, and result contracts. Types only — the + * {@link LspError} taxonomy and the {@link LspProviderId} brand factory are runtime and live in + * `index.ts`. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing + * tool owns the one-based cursor convention. The seam exposes no protocol types, process or document + * controls, or generic JSON-RPC escape hatch — only the four semantic operations. + * @module @deepseek-ai/dsh-lsp/types + */ + +import type { LspProviderId } from './brand.ts' + +/** + * The four semantic queries the seam and model expose. A closed union: adding an operation is a + * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are + * deliberately deferred (they need different schemas). + */ +export type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' + +/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ +export interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} + +/** A zero-based UTF-16 half-open range `[start, end)`. */ +export interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +/** + * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied, + * `languageId` comes from the provider registration (not here), and consumers own timeouts and + * result limits — so no field needs implementation defaulting and there is no `resolve()` step. + */ +export interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} + +/** + * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId` + * the seam derived from the provider's extension mapping. The language id only synchronizes the + * transient document; it does not participate in selection. + */ +export interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} + +/** One resolved location: a document URI and the range within it. */ +export interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} + +/** Normalized hover content, or `null` for no hover at the position. */ +export interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} + +/** + * The closed result union. Navigation operations (`definition`, `references`, `implementation`) + * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` + * to exhaustiveness so a new arm breaks compilation until handled. + */ +export type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'hover'; readonly hover: LspHover | null } + +/** + * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references` + * always includes declarations — the provider enforces this internally; callers get no flag. + */ +export interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +/** + * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query + * execution; exposes exactly the four operations and no protocol escape hatch. + */ +export interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts new file mode 100644 index 0000000000..6746a05dc3 --- /dev/null +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Lsp, { + finalExtension, + LspError, + LspProviderId, + type LspProvider, + type LspProviderQuery, + type LspQueryResult, +} from '@deepseek-ai/dsh-lsp' + +/** A scripted provider that records the queries it receives. */ +function makeProvider( + id: string, + extensionToLanguage: Record, + result: LspQueryResult = { kind: 'locations', locations: [] }, +): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } { + const seen: LspProviderQuery[] = [] + const seenSignals: (AbortSignal | undefined)[] = [] + return { + id: LspProviderId(id), + extensionToLanguage, + seen, + seenSignals, + query(request, signal) { + seen.push(request) + seenSignals.push(signal) + return Promise.resolve(result) + }, + } +} + +/** Mount an Lsp service on a fresh root context. */ +async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> { + const ctx = new Context() + await ctx.plugin(Lsp) + return { ctx, lsp: ctx.lsp as Lsp } +} + +const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } } + +function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters[0] { + return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' } +} + +describe('finalExtension', () => { + it('lowercases and keeps only the final extension', () => { + expect(finalExtension('src/Foo.TS')).toBe('.ts') + expect(finalExtension('a/b/foo.d.ts')).toBe('.ts') + expect(finalExtension('C:\\proj\\Main.CS')).toBe('.cs') + }) + + it('returns empty for no extension or a leading-dot dotfile', () => { + expect(finalExtension('Makefile')).toBe('') + expect(finalExtension('.bashrc')).toBe('') + expect(finalExtension('dir.d/file')).toBe('') + }) +}) + +describe('Lsp registration', () => { + it('registers a provider and routes a query to it, then releases on dispose', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { '.ts': 'typescript' }) + const dispose = lsp.registerProvider(provider) + + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' }) + + dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('normalizes extension keys to lowercase leading-dot and derives the language id', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { TS: 'typescript' }) + lsp.registerProvider(provider) + await lsp.query(query('a.ts')) + expect(provider.seen[0]?.languageId).toBe('typescript') + }) + + it('rejects an empty provider id (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider(' ', { '.ts': 'typescript' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects a provider with no extensions (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', {}))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an invalid extension mapping (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.tar.gz': 'archive' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an empty language id (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': ' ' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an extension mapped twice within one provider (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript', TS: 'ts2' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects a duplicate provider id (LSP_CONFLICT)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + expect(() => lsp.registerProvider(makeProvider('ts', { '.tsx': 'typescriptreact' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + }) + + it('rejects an extension already owned by another provider (LSP_CONFLICT)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + expect(() => lsp.registerProvider(makeProvider('other', { '.ts': 'other-lang' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + }) + + it('publishes nothing when a later extension conflicts (atomic reservation)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + // This provider's `.py` is free but `.ts` conflicts: the whole registration must roll back. + expect(() => lsp.registerProvider(makeProvider('py-ts', { '.py': 'python', '.ts': 'x' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + // `.py` must NOT have been reserved. + await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('releases every extension and the id together on dispose', async () => { + const { lsp } = await mountLsp() + const dispose = lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript', '.tsx': 'typescriptreact' })) + dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await expect(lsp.query(query('a.tsx'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + // The id is free again after release. + expect(() => lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript' }))).not.toThrow() + }) + + it('selection is order-independent across two providers', async () => { + const { lsp } = await mountLsp() + const ts = makeProvider('ts', { '.ts': 'typescript' }, hover) + const py = makeProvider('py', { '.py': 'python' }) + lsp.registerProvider(ts) + lsp.registerProvider(py) + await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover) + }) + + it('forwards the abort signal verbatim to the provider', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { '.ts': 'typescript' }) + lsp.registerProvider(provider) + const controller = new AbortController() + await lsp.query(query('a.ts'), controller.signal) + expect(provider.seenSignals[0]).toBe(controller.signal) + }) + + it('fails LSP_UNAVAILABLE when no provider handles the extension', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => { + const { ctx, lsp } = await mountLsp() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + }, { inject: ['lsp'] })) + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await fiber.dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('LspError carries its structured code', () => { + expect(new LspError('m', 'LSP_UNAVAILABLE').code).toBe('LSP_UNAVAILABLE') + }) + + it('brands a provider id without altering the string', () => { + expect(LspProviderId('ts')).toBe('ts') + }) +}) diff --git a/packages/lsp/lsp/tsconfig.json b/packages/lsp/lsp/tsconfig.json new file mode 100644 index 0000000000..7ca1556695 --- /dev/null +++ b/packages/lsp/lsp/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md new file mode 100644 index 0000000000..fdbd89d96b --- /dev/null +++ b/packages/lsp/tool-lsp/README.md @@ -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. diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json new file mode 100644 index 0000000000..4559bbd1b5 --- /dev/null +++ b/packages/lsp/tool-lsp/package.json @@ -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" + } +} diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts new file mode 100644 index 0000000000..1ec48a3d74 --- /dev/null +++ b/packages/lsp/tool-lsp/src/index.ts @@ -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 = 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 + +/** + * 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 { + 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`) + } +} diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts new file mode 100644 index 0000000000..df0913a591 --- /dev/null +++ b/packages/lsp/tool-lsp/src/render.ts @@ -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() + 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 }], + } +} diff --git a/packages/lsp/tool-lsp/src/session-cwd.ts b/packages/lsp/tool-lsp/src/session-cwd.ts new file mode 100644 index 0000000000..7fc41785de --- /dev/null +++ b/packages/lsp/tool-lsp/src/session-cwd.ts @@ -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 +} diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts new file mode 100644 index 0000000000..f2e8d1c46a --- /dev/null +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -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 { + 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) +}) diff --git a/packages/lsp/tool-lsp/tests/load-path.spec.ts b/packages/lsp/tool-lsp/tests/load-path.spec.ts new file mode 100644 index 0000000000..13dbaa7b78 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/load-path.spec.ts @@ -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 + 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() + }) +}) diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts new file mode 100644 index 0000000000..53a2be5899 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -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 }], + }) + }) +}) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts new file mode 100644 index 0000000000..9cd48ed87f --- /dev/null +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -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 = { '.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 }], + }) + }) +}) diff --git a/packages/lsp/tool-lsp/tsconfig.json b/packages/lsp/tool-lsp/tsconfig.json new file mode 100644 index 0000000000..be656effd2 --- /dev/null +++ b/packages/lsp/tool-lsp/tsconfig.json @@ -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" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e0a2af98a..2cd474142b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,6 +724,80 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/lsp/lsp: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/lsp/lsp-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../lsp + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-language-server: + specifier: ^5.0.0 + version: 5.3.0 + + packages/lsp/tool-lsp: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../lsp + '@deepseek-ai/dsh-lsp-local': + specifier: workspace:^ + version: link:../lsp-local + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../timeout/timeout-policy + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/mcp/mcp-client: dependencies: '@modelcontextprotocol/sdk': @@ -1171,7 +1245,7 @@ importers: devDependencies: cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/subagent-mock: dependencies: @@ -3612,6 +3686,18 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cordis@4.0.0-rc.6: + resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==} + hasBin: true + peerDependencies: + '@cordisjs/plugin-include': ^1.0.4 + '@cordisjs/plugin-loader': ^1.0.0-rc.4 + peerDependenciesMeta: + '@cordisjs/plugin-include': + optional: true + '@cordisjs/plugin-loader': + optional: true + cordis@4.0.0-rc.7: resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==} hasBin: true @@ -5330,6 +5416,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript-language-server@5.3.0: + resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==} + engines: {node: '>=20'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -5471,6 +5562,20 @@ packages: jsdom: optional: true + vscode-jsonrpc@5.0.1: + resolution: {integrity: sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==} + engines: {node: '>=8.0.0 || >=10.0.0'} + + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -5876,6 +5981,14 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6)': + dependencies: + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0) + cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + cosmokit: 1.8.1 + js-yaml: 4.2.0 + optional: true + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)': dependencies: '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) @@ -5891,6 +6004,14 @@ snapshots: js-yaml: 4.2.0 optional: true + '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0)': + dependencies: + cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + cosmokit: 1.8.1 + optionalDependencies: + node-addon-require-builtin: 0.1.0 + optional: true + '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)': dependencies: cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -7037,6 +7158,14 @@ snapshots: cookie@0.7.2: {} + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.6) + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.6)(node-addon-require-builtin@0.1.0) + cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): dependencies: '@standard-schema/spec': 1.1.0 @@ -9038,6 +9167,11 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-language-server@5.3.0: + dependencies: + vscode-jsonrpc: 5.0.1 + vscode-languageserver-protocol: 3.18.2 + typescript@6.0.3: {} unbash@3.0.0: {} @@ -9182,6 +9316,17 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@5.0.1: {} + + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-types@3.18.0: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index ab6b01c10d..fcacdb6ba7 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -26,6 +26,8 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import Lsp from '@deepseek-ai/dsh-lsp' +import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' @@ -147,6 +149,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', }, + { + pkg: '@deepseek-ai/dsh-tool-lsp', + dir: 'tool-lsp', + source: 'packages/lsp/tool-lsp/src/index.ts', + requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The tool registers from the seam alone; the schema does not depend on any provider. + await ctx.plugin(Lsp) + await ctx.plugin(ToolLsp) + }, + note: + 'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.', + }, { pkg: '@deepseek-ai/dsh-tool-skill', dir: 'tool-skill', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 7e7c4ada82..a07a2fb797 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, + 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, + 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a4288b2dd3..d862549da7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,6 +45,7 @@ "./packages/bash/*/src", "./packages/code-runtime/*/src", "./packages/fs/*/src", + "./packages/lsp/*/src", "./packages/skill/*/src", "./packages/compact/*/src", "./packages/context/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index fc1e9f488e..18dbe5bc25 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -83,6 +83,9 @@ { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, { "path": "./packages/hooks/hooks-codex" }, - { "path": "./packages/mcp/mcp-client" } + { "path": "./packages/mcp/mcp-client" }, + { "path": "./packages/lsp/lsp" }, + { "path": "./packages/lsp/lsp-local" }, + { "path": "./packages/lsp/tool-lsp" } ] } diff --git a/tsconfig.json b/tsconfig.json index 02b01678ca..d8a37f0a25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -94,6 +94,9 @@ { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, { "path": "./packages/hooks/hooks-codex" }, - { "path": "./packages/mcp/mcp-client" } + { "path": "./packages/mcp/mcp-client" }, + { "path": "./packages/lsp/lsp" }, + { "path": "./packages/lsp/lsp-local" }, + { "path": "./packages/lsp/tool-lsp" } ] } From 575feaddfaa61dcc281beda215cc032a49cc3aa6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 12:08:06 +0800 Subject: [PATCH 03/15] docs(lsp): regenerate config catalog for optional lsp-local config fields --- docs/config-catalog.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a38130a739..b6b798b124 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -430,26 +430,26 @@ export interface Config { providerId: string /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string - /** Arguments passed to the executable (no shell). */ - args: string[] - /** Extra env vars merged on top of the scrubbed ambient env. */ - env: Record /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ extensionToLanguage: Record - /** Static `initialize` options forwarded to the server. */ - initializationOptions: unknown - /** Static answer to every `workspace/configuration` item. */ - configuration: unknown - /** Largest single framed message accepted from the server (bytes). */ - maxMessageBytes: number - /** Largest stderr tail retained for diagnostics (bytes). */ - maxStderrBytes: number - /** Largest source file this host will open (bytes). */ - maxDocumentBytes: number - /** Graceful `shutdown`/`exit` budget before escalation (ms). */ - shutdownTimeoutMs: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ - killGraceMs: number + /** Arguments passed to the executable (no shell). Default `[]`. */ + args?: string[] + /** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */ + env?: Record + /** Static `initialize` options forwarded to the server. Default `null`. */ + initializationOptions?: unknown + /** Static answer to every `workspace/configuration` item. Default `null`. */ + configuration?: unknown + /** Largest single framed message accepted from the server (bytes). Default 16000000. */ + maxMessageBytes?: number + /** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */ + maxStderrBytes?: number + /** Largest source file this host will open (bytes). Default 4000000. */ + maxDocumentBytes?: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + shutdownTimeoutMs?: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + killGraceMs?: number } ``` From 8e8f90e235dedf21872fa07e314e1d72b93bf36f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 13:11:07 +0800 Subject: [PATCH 04/15] fix(lsp): address codex review round 1 Lifecycle and safety fixes from the external review: - Observe abort while awaiting the initialize handshake, so a server that never replies can't defeat the tool-timeout signal. - On an aborted request the server won't cancel, tear the instance down after a bounded grace instead of releasing the serialized queue with work still live (prevents overlapping document lifecycles). - Re-check provider disposal after the canonicalize/read awaits so a query can't spawn an unowned server after disposeAll(). - Read the source through one open handle (stat + read on the same fd) to close the realpath-vs-read TOCTOU; decode with a fatal UTF-8 decoder so a legitimate U+FFFD is not misclassified as invalid. - Validate and read the source BEFORE spawning a server (pre-start rejection). - Require an explicit openClose for option-form textDocumentSync. - Reject nonpositive teardown budgets and non-executable absolute commands at load; surface unsupported operations as structured LSP_UNSUPPORTED_OPERATION. - Retain the stderr tail (fatal diagnostics land at exit), not the prefix. - Catalog the seam vocabulary in docs/core-data-structures/lsp.md. --- docs/core-data-structures/core.md | 1 + docs/core-data-structures/lsp.md | 124 +++ packages/lsp/lsp-local/src/connection.ts | 5 +- packages/lsp/lsp-local/src/host.ts | 36 +- packages/lsp/lsp-local/src/index.ts | 37 +- packages/lsp/lsp-local/src/instance.ts | 83 +- packages/lsp/lsp-local/src/translate.ts | 10 +- packages/lsp/lsp-local/tests/host.spec.ts | 8 + packages/lsp/lsp-local/tests/instance.spec.ts | 89 +- packages/lsp/lsp-local/tests/provider.spec.ts | 27 + .../lsp/lsp-local/tests/translate.spec.ts | 6 +- scripts/type-equiv.manifest.json | 830 +++++++++++++++--- 12 files changed, 1038 insertions(+), 218 deletions(-) create mode 100644 docs/core-data-structures/lsp.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 8926a8a998..622f91a7c9 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -28,6 +28,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | +| [lsp.md](lsp.md) | the LSP navigation seam: `LspQueryRequest`/`Result`, `LspProvider`/`Service`, four operations, `LspError` | | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md new file mode 100644 index 0000000000..25b98a9e59 --- /dev/null +++ b/docs/core-data-structures/lsp.md @@ -0,0 +1,124 @@ +# LSP navigation + +The LSP seam — a [capability seam](../rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md) exposing semantic code navigation on one `ctx.lsp` service, split across packages: interface ([dsh-lsp](../../packages/lsp/lsp), `ctx.lsp` + the provider registry), a generic implementation ([dsh-lsp-local](../../packages/lsp/lsp-local), a configured stdio language-server host), and consumer ([dsh-tool-lsp](../../packages/lsp/tool-lsp), the `lsp` tool schema). LSP is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A provider swap does not change how the model asks for navigation. + +Source: [`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) + +## Operations and coordinates + +The seam and model expose exactly four semantic queries; the union is closed, so adding one is a compile-enforced change across the seam, providers, and the tool. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing tool owns the one-based cursor convention and converts on the way in and out. + +```ts type-equiv +type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +``` + +```ts type-equiv +interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} +``` + +```ts type-equiv +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} +``` + +## Request + +Every field is required: `workspaceRoot` is caller-supplied, `languageId` comes from the provider's registration (not the request), and consumers own timeouts and result limits — so no field needs implementation defaulting and there is no `resolve()` step. The provider receives the caller's request plus the derived `languageId`, which only synchronizes the transient document and never participates in selection. + +```ts type-equiv +interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} +``` + +```ts type-equiv +interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} +``` + +## Result + +A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. + +```ts type-equiv +interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} +``` + +```ts type-equiv +interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} +``` + +```ts type-equiv +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'hover'; readonly hover: LspHover | null } +``` + +## Provider and service + +A provider owns a stable branded `id` and an exclusive lowercase leading-dot extension map. `registerProvider` reserves the id and every extension atomically — an invalid or conflicting registration publishes nothing — and its disposer releases all reservations. Selection is per query and order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. The seam exposes no protocol types, process/document controls, or generic JSON-RPC escape hatch. + +```ts type-equiv +interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with a stable `code` (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`) callers route on instead of parsing `message`. diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 1eeb430d22..4f1f4d9b3a 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -174,8 +174,9 @@ export class LspConnection { } private onStderr(chunk: Buffer): void { - if (this.stderr.length >= this.spec.maxStderrBytes) return - this.stderr = (this.stderr + chunk.toString('utf8')).slice(0, this.spec.maxStderrBytes) + // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just + // before it exits, so the final bounded segment is the useful one. + this.stderr = (this.stderr + chunk.toString('utf8')).slice(-this.spec.maxStderrBytes) } private dispatch(message: unknown): void { diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts index a90a703d96..8638926860 100644 --- a/packages/lsp/lsp-local/src/host.ts +++ b/packages/lsp/lsp-local/src/host.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-lsp-local/host */ -import { readFile, realpath, stat } from 'node:fs/promises' +import { open, realpath, stat } from 'node:fs/promises' import { isAbsolute, resolve as resolvePath, sep } from 'node:path' /** A validated source: its canonical absolute path and current UTF-8 text. */ @@ -68,16 +68,24 @@ export async function readHostSource( if (!isInside(canonicalWorkspace, canonicalPath)) { throw new Error(`source "${filePath}" resolves outside the workspace`) } - const info = await stat(canonicalPath) - if (!info.isFile()) { - throw new Error(`source "${filePath}" is not a regular file`) + // Open ONE handle after containment, then stat and read through it: a concurrent replace between + // realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we + // actually read (no path-based TOCTOU). + const handle = await open(canonicalPath, 'r') + try { + const info = await handle.stat() + if (!info.isFile()) { + throw new Error(`source "${filePath}" is not a regular file`) + } + if (info.size > maxDocumentBytes) { + throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) + } + const buffer = await handle.readFile() + const text = decodeUtf8Strict(buffer, filePath) + return { canonicalPath, text } + } finally { + await handle.close() } - if (info.size > maxDocumentBytes) { - throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) - } - const buffer = await readFile(canonicalPath) - const text = decodeUtf8Strict(buffer, filePath) - return { canonicalPath, text } } /** Whether `child` is the workspace itself or a descendant of it (both already canonical). */ @@ -88,13 +96,13 @@ function isInside(workspace: string, child: string): boolean { return child.startsWith(base) } -/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */ +/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */ function decodeUtf8Strict(buffer: Buffer, filePath: string): string { - const text = buffer.toString('utf8') - if (text.includes('�')) { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { throw new Error(`source "${filePath}" is not valid UTF-8 text`) } - return text } /** Extract a message from an unknown thrown value without leaking `any`. */ diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index b9c32867da..598254f937 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -23,7 +23,7 @@ import type { } from '@deepseek-ai/dsh-lsp' // Side-effect type import: declaration-merges `ctx.lsp` onto Context. import type {} from '@deepseek-ai/dsh-lsp' -import { canonicalizeWorkspace } from './host.ts' +import { canonicalizeWorkspace, readHostSource } from './host.ts' import { LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' @@ -110,6 +110,10 @@ export const Config: z = z.object({ */ export function apply(ctx: Context, config: Config): void { const resolved = config as ResolvedConfig + // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a + // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. + assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveInteger('killGraceMs', resolved.killGraceMs) const childEnv = buildChildEnv(resolved.env) // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. const executable = resolveExecutable(resolved.command, childEnv) @@ -124,6 +128,13 @@ export function apply(ctx: Context, config: Config): void { }, 'lsp-local.registerProvider') } +/** Reject a nonpositive or non-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(`lsp-local: ${name} must be a positive integer`) + } +} + /** A pooled generic provider: one server process per canonical workspace, created on demand. */ class LocalLspProvider implements LspProvider { readonly id: LspProviderId @@ -141,13 +152,26 @@ class LocalLspProvider implements LspProvider { this.extensionToLanguage = config.extensionToLanguage } + /** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */ + private isDisposed(): boolean { + return this.disposed + } + async query(request: LspProviderQuery, signal?: AbortSignal): Promise { - /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches a disposed provider; defensive. */ - if (this.disposed) throw new Error('lsp-local provider is disposed') + /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */ + if (this.isDisposed()) throw new Error('lsp-local provider is disposed') const workspace = await canonicalizeWorkspace(request.workspaceRoot) + // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized + // source must fail without leaving an idle process pooled (the pre-start rejection contract), and + // the single-handle read preserves the containment/size checks against a mid-read swap. + const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes) + // Re-check disposal after the awaits: disposeAll() may have snapshotted the instance map while we + // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. + /* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */ + if (this.isDisposed()) throw new Error('lsp-local provider is disposed') const instance = await this.instanceFor(workspace) try { - return await instance.query(request, signal) + return await instance.query(request, source, signal) } finally { // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, // but only if the slot still holds THIS instance (a concurrent replacement must survive). @@ -185,7 +209,6 @@ class LocalLspProvider implements LspProvider { initializationOptions: this.config.initializationOptions, maxMessageBytes: this.config.maxMessageBytes, maxStderrBytes: this.config.maxStderrBytes, - maxDocumentBytes: this.config.maxDocumentBytes, shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } @@ -232,6 +255,10 @@ function buildChildEnv(extra: Record): Record { */ function resolveExecutable(command: string, childEnv: Record): string { if (isAbsolute(command)) { + // Verify an absolute command too, so an unavailable one fails at load, not on the first query. + if (!isExecutableSync(command)) { + throw new Error(`lsp-local: command "${command}" is not an executable file`) + } return command } /* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */ diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 0e63e46d1e..83266e9c8f 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -8,6 +8,7 @@ */ import { pathToFileURL } from 'node:url' +import { LspError } from '@deepseek-ai/dsh-lsp' import type { LspOperation, LspProviderQuery, @@ -16,7 +17,7 @@ import type { import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { LspConnection } from './connection.ts' import type { ConnectionSpec } from './connection.ts' -import { readHostSource } from './host.ts' +import type { HostSource } from './host.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import { negotiatePositionEncoding, @@ -31,8 +32,6 @@ import { export interface InstanceSpec extends ConnectionSpec { /** Static `initialize` options forwarded to the server. */ readonly initializationOptions: unknown - /** Largest source file this host will open (bytes). */ - readonly maxDocumentBytes: number /** Graceful `shutdown`/`exit` budget before escalation (ms). */ readonly shutdownTimeoutMs: number /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ @@ -74,11 +73,12 @@ export class LspInstance { /** * Run one query through the serialized queue. * @param request - the resolved provider query. + * @param source - the pre-validated, already-read host source (the provider reads before spawning). * @param signal - optional cancellation for this query's full lifecycle. * @returns the normalized result. */ - query(request: LspProviderQuery, signal?: AbortSignal): Promise { - const run = this.queue.then(() => this.runQuery(request, signal)) + query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { + const run = this.queue.then(() => this.runQuery(request, source, signal)) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. this.queue = run.then(() => undefined, () => undefined) return run @@ -99,24 +99,26 @@ export class LspInstance { this.connection.notify('initialized', {}) } - private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise { + private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { if (this.disposed) throw new Error('LSP instance was disposed') if (signal?.aborted) throw abortError(signal) - await this.ready + // Observe abort during the handshake wait: a server that never answers `initialize` must not + // block the tool-timeout signal here (the timeout policy awaits our quiescence, not the promise). + await this.abortable(this.ready, signal) const capabilities = this.capabilities /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ if (capabilities === undefined) throw new Error('LSP instance is not initialized') if (!supportsOperation(capabilities, request.operation)) { - throw new Error(`server does not support ${request.operation}`) + throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } if (!supportsTransientOpen(capabilities.textDocumentSync)) { - throw new Error('server does not support the transient textDocument/didOpen this host requires') + throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION') } - const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes) const uri = pathToFileURL(source.canonicalPath).href let opened = false try { + /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) this.connection.notify('textDocument/didOpen', { textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, @@ -125,7 +127,10 @@ export class LspInstance { const payload = await this.sendRequest(request.operation, uri, request.position, signal) return this.normalize(request.operation, payload) } finally { - if (opened) { + // A disposed or closed instance (e.g. an aborted request whose server ignored + // `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let + // the next queued query's document lifecycle overlap the still-active request. + if (opened && !this.dead) { try { this.connection.notify('textDocument/didClose', { textDocument: { uri } }) } catch (error) { @@ -141,6 +146,21 @@ export class LspInstance { } } + /** + * Await `work`, but reject as soon as `signal` aborts. The underlying `work` promise keeps its own + * handlers, so an orphaned rejection after abort is not unhandled. + */ + private abortable(work: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return work + /* v8 ignore next -- runQuery checks signal.aborted before each abortable() call, so it is not already aborted here; defensive. */ + if (signal.aborted) return Promise.reject(abortError(signal)) + return new Promise((resolve, reject) => { + const onAbort = (): void => { reject(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + work.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort) }) + }) + } + private async sendRequest( operation: LspOperation, uri: string, @@ -160,21 +180,33 @@ export class LspInstance { return this.raceAbort(send, requestId, signal) } - /** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */ + /** + * Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a + * bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the + * instance so the still-active request cannot overlap the next queued query's document lifecycle. + */ private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { - const abort = new Promise((_, reject) => { - const onAbort = (): void => { reject(abortError(signal)) } - /* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */ - if (signal.aborted) { onAbort(); return } - signal.addEventListener('abort', onAbort, { once: true }) - // Remove the abort listener once the request settles either way; the finally-promise inherits - // send's rejection, so catch it to avoid an unhandled rejection when abort already won. - send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {}) - }) try { - return await Promise.race([send, abort]) + return await this.abortable(send, signal) } catch (error) { - if (signal.aborted) this.connection.cancel(requestId) + if (!signal.aborted) throw error + this.connection.cancel(requestId) + // Wait, bounded, for the server to honor the cancellation. If it does not, the request is still + // running: terminate the instance (disposal awaits process close) so nothing outlives the query. + using grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') + // `settled` is true if the request finished (either outcome) before the grace elapsed. + const settled = await Promise.race([ + send.then(markSettled, markSettled), + new Promise((resolve) => { + /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ + if (grace.signal.aborted) { resolve(false); return } + grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + ]) + if (!settled && !this.disposed) { + this.disposed = true + await this.tearDown(abortError(signal)) + } throw error } } @@ -266,6 +298,11 @@ const LIFECYCLE_NOOP_METHODS = new Set([ 'client/unregisterCapability', ]) +/** Mark a settled request in the cancel-grace race (either outcome means the request finished). */ +function markSettled(): boolean { + return true +} + /** Build an abort Error carrying the signal's reason (preserving a timeout classification). */ function abortError(signal: AbortSignal): Error { const timeout = timeoutOf(signal) diff --git a/packages/lsp/lsp-local/src/translate.ts b/packages/lsp/lsp-local/src/translate.ts index a212d283b6..a208c3bedf 100644 --- a/packages/lsp/lsp-local/src/translate.ts +++ b/packages/lsp/lsp-local/src/translate.ts @@ -21,7 +21,6 @@ import type { WireRange, WireServerCapabilities, WireTextDocumentSyncKind, - WireTextDocumentSyncOptions, } from './protocol.ts' /** @@ -71,13 +70,15 @@ export function supportsOperation(capabilities: WireServerCapabilities, operatio /** * Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on. + * The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an + * explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false. * @param sync - the server's advertised `textDocumentSync` capability. * @returns true when transient open/close is supported. */ export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean { if (sync === undefined) return false if (typeof sync === 'number') return isOpenCloseKind(sync) - return sync.openClose === true || (sync.openClose === undefined && changeAllowsOpenClose(sync)) + return sync.openClose === true } /** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */ @@ -85,11 +86,6 @@ function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean { return kind === 1 || kind === 2 } -/** Options without an explicit `openClose` fall back to the legacy `change` enum's implication. */ -function changeAllowsOpenClose(sync: WireTextDocumentSyncOptions): boolean { - return sync.change !== undefined && isOpenCloseKind(sync.change) -} - /** * Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value * other than `utf-16` is a protocol error this host does not support. diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts index b76aa556e2..3fb9f804ac 100644 --- a/packages/lsp/lsp-local/tests/host.spec.ts +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -102,4 +102,12 @@ describe('readHostSource', () => { await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00])) await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/) }) + + it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => { + // The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed + // byte sequences are rejected). + await writeFile(join(ws, 'repl.ts'), 'const s = "�"\n') + const source = await readHostSource('repl.ts', ws, BIG) + expect(source.text).toBe('const s = "�"\n') + }) }) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index da3231f133..83c4bdfe77 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -3,9 +3,9 @@ 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 { LspInstance, readHostSource } 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' +import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -38,7 +38,6 @@ function makeInstance(env: Record = {}, overrides: Partial { + const source = await readHostSource('a.ts', ws, 4_000_000) + return instance.query(query(operation), source, signal) +} + /** Build an instance whose "server" is an inline node script (for teardown-escalation control). */ function scriptInstance(script: string, overrides: Partial = {}): LspInstance { const instance = new LspInstance({ @@ -62,7 +67,6 @@ function scriptInstance(script: string, overrides: Partial = {}): initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - maxDocumentBytes: 4_000_000, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, @@ -87,51 +91,98 @@ describe('LspInstance server-request handling', () => { 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' }) + await expect(run(instance, '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: [] }) + await expect(run(instance, '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: [] }) + await expect(run(instance, '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: [] }) + await expect(run(instance, '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' }) + await expect(run(instance, '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/) + await expect(run(instance, '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) + const pending = run(instance, 'definition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) }) + it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => { + // The hang server never honors cancellation, so after the bounded grace the instance must be torn + // down (its process closed) rather than left with an active request. + const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 }) + const controller = new AbortController() + const pending = run(instance, 'definition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + expect(instance.dead).toBe(true) + }) + + it('resolves the cancel grace when the server honors $/cancelRequest', async () => { + // A server that answers $/cancelRequest by settling the pending request lets the grace race + // resolve via the request rather than the timeout, so the instance is NOT force-terminated. + const script = 'let b=Buffer.alloc(0),reqId=null;' + + '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(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + // The server acknowledged cancellation within grace, so the instance was not force-killed. + expect(instance.dead).toBe(false) + await instance.dispose() + }) + + it('observes abort while awaiting a slow initialize handshake', async () => { + // A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be + // observed during that wait instead of hanging the tool-timeout signal. + const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 }) + const controller = new AbortController() + const pending = run(instance, 'definition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 150)) + controller.abort(new Error('handshake-abort')) + await expect(pending).rejects.toThrow(/handshake-abort/) + await instance.dispose() + }) + 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/) + await expect(run(instance, 'definition')).rejects.toThrow(/does not support definition/) }) it('propagates a server error response even when a signal is supplied (not an abort)', async () => { @@ -139,28 +190,28 @@ describe('LspInstance query and abort', () => { // 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/) + await expect(run(instance, '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 run(instance, '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 run(instance, 'definition') await instance.dispose() - await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/) + await expect(run(instance, '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 run(instance, 'definition') await instance.dispose() expect(instance.dead).toBe(true) }) @@ -169,14 +220,14 @@ describe('LspInstance disposal', () => { // 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 run(instance, '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) + const pending = run(instance, 'definition', controller.signal) await new Promise(resolve => setTimeout(resolve, 200)) controller.abort('a string reason, not an Error') await expect(pending).rejects.toThrow(/aborted/) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 5d4045f507..20978df8d5 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -75,4 +75,31 @@ describe('lsp-local provider resolution', () => { await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) await ctx.fiber.dispose() }) + + it('rejects a nonpositive teardown budget at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'bad-budget', + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + killGraceMs: 0, + })).rejects.toThrow(/killGraceMs must be a positive integer/) + await ctx.fiber.dispose() + }) + + it('rejects an absolute command that is not executable at load', async () => { + const notExe = join(root, 'not-exe.txt') + await writeFile(notExe, 'plain text, not executable') + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'abs-bad', + command: notExe, + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) }) diff --git a/packages/lsp/lsp-local/tests/translate.spec.ts b/packages/lsp/lsp-local/tests/translate.spec.ts index 2c339075e5..7727112089 100644 --- a/packages/lsp/lsp-local/tests/translate.spec.ts +++ b/packages/lsp/lsp-local/tests/translate.spec.ts @@ -47,9 +47,9 @@ describe('supportsTransientOpen', () => { 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) + it('requires an explicit openClose for the options form (no change-enum fallback)', () => { + expect(supportsTransientOpen({ change: 1 })).toBe(false) + expect(supportsTransientOpen({ change: 2 })).toBe(false) expect(supportsTransientOpen({})).toBe(false) }) }) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 51d98c659e..0dcd375c8a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,150 +1,690 @@ { "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ - { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Message", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "MessageSourceMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" }, - - { "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" }, - { "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" }, - { "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" }, - - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" }, - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" }, - { "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, - - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, - { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, - - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEventMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "EpochHeader", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TodoItem", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnTriggerMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, - { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, - - { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, - { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, - - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, - { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, - - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredScalar", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaType", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" }, - { "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" }, - - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswer", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" }, - { "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" }, - - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequestId", "source": "packages/ui/user-approval/src/index.ts" }, - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalOutcome", "source": "packages/ui/user-approval/src/index.ts" }, - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalPolicy", "source": "packages/ui/user-approval/src/index.ts" }, - { "doc": "docs/core-data-structures/approval.md", "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, - - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, - - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedSandboxMode", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxEnforcement", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "SandboxPolicy", "source": "packages/sandbox/sandbox/src/index.ts" }, - { "doc": "docs/core-data-structures/sandbox.md", "symbol": "ConfinedArgv", "source": "packages/sandbox/sandbox/src/index.ts" }, - - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunRequest", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - { "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" }, - - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" }, - - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSource", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillResourceBase", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillSummary", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillCandidate", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillDefinition", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillRegistration", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillLookupOptions", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "SkillProvider", "source": "packages/skill/skill/src/index.ts" }, - { "doc": "docs/core-data-structures/skills.md", "symbol": "Config", "source": "packages/skill/skill/src/index.ts" }, - - { "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" }, - - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, - - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, - - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" } + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Branded", + "source": "packages/util/brand/src/index.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Message", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "MessageSourceMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "FinishReasonMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "GenerateOptions", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ToolSchema", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmCallConfig", + "source": "packages/llm/llm/src/call-config.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "Agent", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "HookContext", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "PromptDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContinuationDecision", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ContinuationStop", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "SessionStartSource", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/scope.md", + "symbol": "ScopeKey", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/scope.md", + "symbol": "Scoped", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/scope.md", + "symbol": "Scope", + "source": "packages/core/scope/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.md", + "symbol": "AssembleContext", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.md", + "symbol": "PromptSection", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/system-prompt.md", + "symbol": "ToolProviderResult", + "source": "packages/core/system-prompt/src/index.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "StreamChunk", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "TokenUsage", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "ContentBlockMap", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/llm-streaming.md", + "symbol": "AppIdentity", + "source": "packages/llm/llm/src/attribution.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEventMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "EpochHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TodoItem", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SessionEvent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnTriggerMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnEndReasonMap", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceEventType", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceOp", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceIntent", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceNode", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceFoldReplacement", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "SurfaceFoldResult", + "source": "packages/core/session/src/surface.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionHeader", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "CreateSessionOptions", + "source": "packages/core/session/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventSurface", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionRecord", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventRecord", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionQueryErrorCode", + "source": "packages/session-query/session-query/src/config.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventReadRequest", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventWindow", + "source": "packages/session-query/session-query/src/types.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolDefinition", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaProp", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "SchemaSpec", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "InferArgs", + "source": "packages/core/tools/src/schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecutionToken", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecutionInput", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecution", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolGuard", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolRestriction", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "ToolExecutionResult", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "PreToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "PostToolDecision", + "source": "packages/core/tools/src/index.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredScalar", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredSchemaType", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredSchemaNode", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "StructuredOutputSchema", + "source": "packages/core/tools/src/json-schema.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionOption", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionItem", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionRequest", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionAnswerItem", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "AskUserQuestionAnswer", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "UserInteractionProvider", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/user-interaction.md", + "symbol": "UserInteractionError", + "source": "packages/ui/user-interaction/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalRequestId", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalOutcome", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalPolicy", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/approval.md", + "symbol": "ApprovalRequest", + "source": "packages/ui/user-approval/src/index.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashExecRequest", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashExecSpec", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashRunResult", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashSandboxInfo", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "CollectedOutput", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTask", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/bash.md", + "symbol": "BashTaskRead", + "source": "packages/bash/bash/src/types.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxMode", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "ConfinedSandboxMode", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxEnforcement", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "SandboxPolicy", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/sandbox.md", + "symbol": "ConfinedArgv", + "source": "packages/sandbox/sandbox/src/index.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeRunRequest", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeRunResult", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeBindingNamespace", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeBindingFunction", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/code-runtime.md", + "symbol": "CodeRunFailure", + "source": "packages/code-runtime/code-runtime/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTarget", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsTargetKey", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsVersion", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsInfo", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsDirEntry", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteIntent", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsWriteOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditRequest", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsEditOutcome", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsErrorCode", + "source": "packages/fs/fs/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FsPolicyExec", + "source": "packages/fs/fs-policy/src/types.ts" + }, + { + "doc": "docs/core-data-structures/filesystem.md", + "symbol": "FileReadOutcome", + "source": "packages/fs/tool-fs/src/read-render.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillSource", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillResourceBase", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillSummary", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillCandidate", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillDefinition", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillRegistration", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillLookupOptions", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "SkillProvider", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/skills.md", + "symbol": "Config", + "source": "packages/skill/skill/src/index.ts" + }, + { + "doc": "docs/core-data-structures/compaction.md", + "symbol": "CompactionResult", + "source": "packages/compact/compact/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentCapabilities", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStartRequest", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentResult", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentStopReasonMap", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentRun", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentProvider", + "source": "packages/subagent/subagent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebSearchSource", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchRequest", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchResult", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/web.md", + "symbol": "WebFetchBody", + "source": "packages/web/web/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowStartRequest", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowMeta", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowResult", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/workflow.md", + "symbol": "WorkflowRun", + "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspOperation", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspPosition", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspRange", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspQueryRequest", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspProviderQuery", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspLocation", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspHover", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspQueryResult", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspProvider", + "source": "packages/lsp/lsp/src/types.ts" + }, + { + "doc": "docs/core-data-structures/lsp.md", + "symbol": "LspService", + "source": "packages/lsp/lsp/src/types.ts" + } ] } From 0f3f0efd9c08b755d6127ef8b77100a34170a512 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 13:34:37 +0800 Subject: [PATCH 05/15] fix(lsp): address codex review round 2 Further lifecycle/safety hardening of the local provider: - Tear the instance down when the initialize handshake is aborted, so a poisoned pending `ready` can't make later queries for that workspace re-wait. - Make the serialized-queue wait itself abortable, so a query blocked behind hung earlier work can still observe its own timeout. - Spawn the server detached and signal the whole process group on teardown, so helper processes (e.g. tsserver) can't outlive dispose(). - Open the source with O_NOFOLLOW and cap the read at maxDocumentBytes+1, closing the symlink-swap and concurrent-grow windows the fd-based read left open. - Honor an already-aborted signal before any host I/O or startup. - Validate maxStderrBytes positive at load; surface the retained stderr tail in the "language server exited" error so a fatal startup diagnostic is visible. --- packages/lsp/lsp-local/src/connection.ts | 38 ++++++++++++++++--- packages/lsp/lsp-local/src/host.ts | 28 ++++++++++++-- packages/lsp/lsp-local/src/index.ts | 7 +++- packages/lsp/lsp-local/src/instance.ts | 32 +++++++++++++--- .../lsp/lsp-local/tests/lifecycle.spec.ts | 19 ++++++++++ 5 files changed, 109 insertions(+), 15 deletions(-) diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 4f1f4d9b3a..272109d2c6 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -55,14 +55,17 @@ export class LspConnection { private readonly onServerRequest: (method: string, params: unknown) => Promise, ) { this.decoder = new MessageDecoder(spec.maxMessageBytes) + // `detached` puts the server in its own process group so teardown can signal the WHOLE group + // (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver). this.child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: spec.env, stdio: ['pipe', 'pipe', 'pipe'], + detached: true, }) this.closed = new Promise((resolve) => { this.child.on('close', () => { - const reason = this.closeReason ?? new Error('language server exited') + const reason = this.closeReason ?? new Error(this.exitMessage()) // Record the reason so any request issued AFTER close rejects immediately instead of hanging // (a closed process sends no further responses). this.closeReason = reason @@ -150,14 +153,33 @@ export class LspConnection { return this.nextId } - /** Send SIGTERM to the child (idempotent-safe; a dead child ignores it). */ + /** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */ terminate(): void { - this.child.kill('SIGTERM') + this.signalGroup('SIGTERM') } - /** Send SIGKILL to the child. */ + /** Send SIGKILL to the server's process group. */ kill(): void { - this.child.kill('SIGKILL') + this.signalGroup('SIGKILL') + } + + /** + * Signal the whole process group (negative pid) so helper processes are reached; fall back to the + * direct child if the group send fails. Never throws — teardown races process exit. + */ + private signalGroup(sig: NodeJS.Signals): void { + const pid = this.child.pid + if (pid === undefined) return + try { + process.kill(-pid, sig) + } catch { + // The group is gone (already exited) or could not be signalled; try the direct child. + try { + this.child.kill(sig) + } catch { + // Already dead; nothing to signal. + } + } } private onStdout(chunk: Buffer): void { @@ -221,6 +243,12 @@ export class LspConnection { this.child.stdin.write(encodeMessage(message)) } + /** The exit-close error message, appending the retained stderr tail when the server wrote any. */ + private exitMessage(): string { + const tail = this.stderr.trim() + return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` + } + private fail(error: Error): void { /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */ if (this.closeReason === undefined) this.closeReason = error diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts index 8638926860..949a1b838b 100644 --- a/packages/lsp/lsp-local/src/host.ts +++ b/packages/lsp/lsp-local/src/host.ts @@ -9,7 +9,9 @@ * @module @deepseek-ai/dsh-lsp-local/host */ +import { constants } from 'node:fs' import { open, realpath, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' import { isAbsolute, resolve as resolvePath, sep } from 'node:path' /** A validated source: its canonical absolute path and current UTF-8 text. */ @@ -70,8 +72,9 @@ export async function readHostSource( } // Open ONE handle after containment, then stat and read through it: a concurrent replace between // realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we - // actually read (no path-based TOCTOU). - const handle = await open(canonicalPath, 'r') + // actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a + // symlink between realpath and open (which would otherwise escape the workspace). + const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW) try { const info = await handle.stat() if (!info.isFile()) { @@ -80,7 +83,9 @@ export async function readHostSource( if (info.size > maxDocumentBytes) { throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) } - const buffer = await handle.readFile() + // Bound the read to the cap even if the file grew after stat: read one extra byte and reject on + // overflow, so a concurrent grow cannot defeat the memory bound. + const buffer = await readCapped(handle, maxDocumentBytes, filePath) const text = decodeUtf8Strict(buffer, filePath) return { canonicalPath, text } } finally { @@ -88,6 +93,23 @@ export async function readHostSource( } } +/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */ +async function readCapped(handle: FileHandle, maxBytes: number, filePath: string): Promise { + const limit = maxBytes + 1 + const chunk = Buffer.allocUnsafe(limit) + let total = 0 + for (;;) { + const { bytesRead } = await handle.read(chunk, total, limit - total, total) + if (bytesRead === 0) break + total += bytesRead + /* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */ + if (total > maxBytes) { + throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`) + } + } + return chunk.subarray(0, total) +} + /** Whether `child` is the workspace itself or a descendant of it (both already canonical). */ function isInside(workspace: string, child: string): boolean { if (child === workspace) return true diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 598254f937..dc6e02d3c6 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -24,7 +24,7 @@ import type { // Side-effect type import: declaration-merges `ctx.lsp` onto Context. import type {} from '@deepseek-ai/dsh-lsp' import { canonicalizeWorkspace, readHostSource } from './host.ts' -import { LspInstance } from './instance.ts' +import { abortError, LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' export { canonicalizeWorkspace, readHostSource } from './host.ts' @@ -114,6 +114,8 @@ export function apply(ctx: Context, config: Config): void { // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) assertPositiveInteger('killGraceMs', resolved.killGraceMs) + // A nonpositive stderr cap defeats the retained-tail bound (`slice(-0)` keeps everything). + assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes) const childEnv = buildChildEnv(resolved.env) // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. const executable = resolveExecutable(resolved.command, childEnv) @@ -160,6 +162,9 @@ class LocalLspProvider implements LspProvider { async query(request: LspProviderQuery, signal?: AbortSignal): Promise { /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */ if (this.isDisposed()) throw new Error('lsp-local provider is disposed') + // Honor an already-aborted signal before any host I/O so a canceled request neither reads nor + // spawns a server. + if (signal?.aborted) throw abortError(signal) const workspace = await canonicalizeWorkspace(request.workspaceRoot) // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized // source must fail without leaving an idle process pooled (the pre-start rejection contract), and diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 83266e9c8f..ecf2782a12 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -78,9 +78,14 @@ export class LspInstance { * @returns the normalized result. */ query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { - const run = this.queue.then(() => this.runQuery(request, source, signal)) - // Keep the tail alive regardless of this query's outcome so the next caller still serializes. - this.queue = run.then(() => undefined, () => undefined) + // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query + // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up + // rather than block on the shared tail forever. + const run = this.abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) + // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The + // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up + // on the wait does not deserialize the queue. + this.queue = this.queue.then(() => run).then(() => undefined, () => undefined) return run } @@ -101,10 +106,21 @@ export class LspInstance { private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { if (this.disposed) throw new Error('LSP instance was disposed') + /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ if (signal?.aborted) throw abortError(signal) // Observe abort during the handshake wait: a server that never answers `initialize` must not // block the tool-timeout signal here (the timeout policy awaits our quiescence, not the promise). - await this.abortable(this.ready, signal) + // If abort wins, the handshake is still pending on a live process, so tear the instance down — + // otherwise its poisoned `ready` would make every later query for this workspace re-wait. + try { + await this.abortable(this.ready, signal) + } catch (error) { + if (signal?.aborted && !this.dead) { + this.disposed = true + await this.tearDown(abortError(signal)) + } + throw error + } const capabilities = this.capabilities /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ if (capabilities === undefined) throw new Error('LSP instance is not initialized') @@ -303,8 +319,12 @@ function markSettled(): boolean { return true } -/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */ -function abortError(signal: AbortSignal): Error { +/** + * Build an abort Error carrying the signal's reason (preserving a timeout classification). + * @param signal - the aborted signal whose reason to surface. + * @returns the timeout reason if the signal carries one, else the signal's Error reason, else a generic aborted Error. + */ +export function abortError(signal: AbortSignal): Error { const timeout = timeoutOf(signal) if (timeout !== undefined) return timeout const reason: unknown = signal.reason diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index c5631f2a95..66a18584a1 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -151,6 +151,25 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('honors an already-aborted signal before any host I/O or startup', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + controller.abort(new Error('pre-aborted')) + await expect(ctx.lsp.query(query('definition'), controller.signal)).rejects.toThrow(/pre-aborted/) + await ctx.fiber.dispose() + }) + + it('surfaces the server stderr tail in the exit error', async () => { + // A server that writes to stderr then exits without answering: the query rejection carries the + // retained stderr tail so the failure is diagnosable. + const ctx = await mount({}, { + command: process.execPath, + args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'], + }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/FATAL: boom/) + await ctx.fiber.dispose() + }) + it('classifies a timeout deadline as the abort reason', async () => { const ctx = await mount({ LSP_FAKE_HANG: '1' }) using d = deadline(undefined, 50, 'TEST_TIMEOUT') From 43d419ac5c1da0bc3e3920eb6fe089597350330c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 14:17:21 +0800 Subject: [PATCH 06/15] fix(lsp): address codex review round 3 Final review pass on the local provider: - Tear the instance down when `initialize` REJECTS (utf-8 negotiation, malformed result), not only on abort, so a permanently-rejecting `ready` is never pooled. - Use the group-aware SIGKILL on a framing failure so helpers are reached. - Validate maxMessageBytes and maxDocumentBytes positive at load alongside the other byte caps. - Fix the location renderer's outside-workspace check to match a `..` segment exactly, so an in-workspace path like `..generated/a.ts` stays relative. - Document the accepted ancestor-directory symlink-swap TOCTOU under the trusted-host model (O_NOFOLLOW guards only the final component). --- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/src/connection.ts | 5 +++-- packages/lsp/lsp-local/src/index.ts | 6 +++++- packages/lsp/lsp-local/src/instance.ts | 13 +++++++------ packages/lsp/lsp-local/tests/lifecycle.spec.ts | 10 ++++++++++ packages/lsp/tool-lsp/src/render.ts | 4 +++- packages/lsp/tool-lsp/tests/render.spec.ts | 6 ++++++ 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index f9e4b32713..1424f27835 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -44,6 +44,6 @@ Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized re ## Known Limitations and Deferred Work -- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. - **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. - **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 272109d2c6..6eea8aec27 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -187,9 +187,10 @@ export class LspConnection { try { messages = this.decoder.push(chunk) } catch (error) { - // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance. + // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and + // SIGKILL the whole group so helper processes don't outlive the leader. this.fail(asError(error)) - this.child.kill('SIGKILL') + this.signalGroup('SIGKILL') return } for (const message of messages) this.dispatch(message) diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index dc6e02d3c6..19d29e3618 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -114,8 +114,12 @@ export function apply(ctx: Context, config: Config): void { // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) assertPositiveInteger('killGraceMs', resolved.killGraceMs) - // A nonpositive stderr cap defeats the retained-tail bound (`slice(-0)` keeps everything). + // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound + // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad + // document cap fails later in the read path instead of at load. assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes) + assertPositiveInteger('maxMessageBytes', resolved.maxMessageBytes) + assertPositiveInteger('maxDocumentBytes', resolved.maxDocumentBytes) const childEnv = buildChildEnv(resolved.env) // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. const executable = resolveExecutable(resolved.command, childEnv) diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index ecf2782a12..cd73d5cb8d 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -108,16 +108,17 @@ export class LspInstance { if (this.disposed) throw new Error('LSP instance was disposed') /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ if (signal?.aborted) throw abortError(signal) - // Observe abort during the handshake wait: a server that never answers `initialize` must not - // block the tool-timeout signal here (the timeout policy awaits our quiescence, not the promise). - // If abort wins, the handshake is still pending on a live process, so tear the instance down — - // otherwise its poisoned `ready` would make every later query for this workspace re-wait. + // Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends + // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8 + // negotiation, malformed result) without the process exiting — tear the instance down so a + // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. try { await this.abortable(this.ready, signal) } catch (error) { - if (signal?.aborted && !this.dead) { + if (!this.dead) { this.disposed = true - await this.tearDown(abortError(signal)) + /* v8 ignore next -- ready rejects with an Error (abort reason or initialize failure); the String() fallback is defensive. */ + await this.tearDown(error instanceof Error ? error : new Error(String(error))) } throw error } diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 66a18584a1..bf624a1c56 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -105,6 +105,16 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('does not pool a poisoned instance when initialize rejects', async () => { + // A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a + // permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it. + const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + // A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one. + await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await ctx.fiber.dispose() + }) + it('rejects a server without transient-open sync (None)', async () => { const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/) diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts index df0913a591..0051adc719 100644 --- a/packages/lsp/tool-lsp/src/render.ts +++ b/packages/lsp/tool-lsp/src/render.ts @@ -137,7 +137,9 @@ export function renderUri(uri: string, workspaceRoot: string): string { } const rel = relative(workspaceRoot, absolute) if (rel === '') return '.' - const outside = rel.startsWith('..') || isAbsolute(rel) + // A leading `..` SEGMENT (or an absolute rel) means outside the workspace; guard against a false + // positive on an in-workspace path whose first component merely starts with dots (e.g. `..gen/x`). + const outside = rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) return outside ? absolute : rel.split(sep).join('/') } diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 53a2be5899..88b02a1fd5 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -60,6 +60,12 @@ describe('renderUri', () => { expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.') }) + it('keeps an in-workspace path whose first segment starts with dots relative', () => { + // `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external. + const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href + expect(renderUri(uri, WS)).toBe('..generated/a.ts') + }) + 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') From 3368f7924f823d193e86a92222cec417e42c8209 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 14:57:07 +0800 Subject: [PATCH 07/15] docs(lsp): resolve ds-review-bot RFC warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the executable-resolution lifecycle contradiction to match the shipped implementation: the executable resolves eagerly at load (failing before registration if unavailable), while the server process launch stays lazy until the first query. Align both language versions. Correct two zh counterpart divergences: drop the "only" condition the Chinese text added to the English "behave best" source modality, and apply binding terminology (包(package)/包, transcript(文本记录)). --- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 ++-- .../architecture/2026-07-15-lsp-capability-seam.md | 2 +- .../2026-07-15-lsp-capability-seam.zh.md | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index f8b32dcca1..33a1d6c036 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 90cc7fce8ce86582cc27bd70fadcc46309438983 -2026-07-15-lsp-capability-seam.zh.md: 12873d33684255b7177a78dd4588e11aa61eb26b +2026-07-15-lsp-capability-seam.md: 77b544efbcef1f352806d0431f1ff705d2b7bd44 +2026-07-15-lsp-capability-seam.zh.md: dad8160f0bb3a08b2b6546429c3826689a6307f8 diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index 90cc7fce8c..77b544efbc 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -129,7 +129,7 @@ The canonical workspace `realpath` must be a directory and supplies process cwd, ## Local server lifecycle and protocol behavior -`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; resolution stays lazy and launch uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter. +`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; server process launch stays lazy (first query spawns it) and uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter. Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits. diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 12873d3368..dad8160f0b 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -10,11 +10,11 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程,并阻碍远程或沙箱原生提供方。 -许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 +许多语言服务器在查询文档已按当前文本打开时表现最佳。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 ## 决策 -将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: +将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: 1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。 @@ -114,7 +114,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p `dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一句柄。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 -`read` 工具的输出带窗口与行号,进入 transcript 且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 +`read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 @@ -129,7 +129,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p ## 本地服务器生命周期与协议行为 -`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败,解析保持懒执行,启动不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 +`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败。服务器进程的启动保持懒执行(首次查询时才拉起),且不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 @@ -173,7 +173,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p ## 测试 -- Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 +- 包测试固定三个包的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 - 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 - 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `references.includeDeclaration`。 @@ -183,7 +183,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 - 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。 - 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 -- Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 +- 包与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` 包组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 ## 影响 From 0d8e7e98f7a4a9d239ec7af000e951c0c0909a2d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 16:42:32 +0800 Subject: [PATCH 08/15] fix(lsp): harden local provider lifecycle --- docs/core-data-structures/lsp.md | 4 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 4 +- .../2026-07-15-lsp-capability-seam.zh.md | 4 +- packages/lsp/lsp-local/src/connection.ts | 18 +++++++-- packages/lsp/lsp-local/src/framing.ts | 3 ++ packages/lsp/lsp-local/src/index.ts | 40 +++++++++++++------ packages/lsp/lsp-local/src/instance.ts | 21 +++++----- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 1 - .../lsp/lsp-local/tests/connection.spec.ts | 7 ++++ .../lsp/lsp-local/tests/fixture-server.ts | 30 ++++++++++++++ packages/lsp/lsp-local/tests/framing.spec.ts | 6 +++ packages/lsp/lsp-local/tests/instance.spec.ts | 20 ++++++++-- .../lsp/lsp-local/tests/lifecycle.spec.ts | 30 +++++++++++++- packages/lsp/lsp-local/tests/provider.spec.ts | 12 ++++++ packages/lsp/lsp/README.md | 2 +- packages/lsp/lsp/src/types.ts | 7 +++- packages/lsp/lsp/tests/lsp.spec.ts | 8 ++-- packages/lsp/tool-lsp/README.md | 2 +- packages/lsp/tool-lsp/src/index.ts | 5 ++- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 16 ++++++++ 21 files changed, 192 insertions(+), 52 deletions(-) diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md index 25b98a9e59..3067237282 100644 --- a/docs/core-data-structures/lsp.md +++ b/docs/core-data-structures/lsp.md @@ -54,7 +54,7 @@ interface LspProviderQuery extends LspQueryRequest { ## Result -A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. +A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root. ```ts type-equiv interface LspLocation { @@ -76,7 +76,7 @@ interface LspHover { ```ts type-equiv type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: LspHover | null } ``` diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 33a1d6c036..69c509bae6 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 77b544efbcef1f352806d0431f1ff705d2b7bd44 -2026-07-15-lsp-capability-seam.zh.md: dad8160f0bb3a08b2b6546429c3826689a6307f8 +2026-07-15-lsp-capability-seam.md: c21d2f3926b5b465f4ccf48f1bf954c2ae604e12 +2026-07-15-lsp-capability-seam.zh.md: 9556ead6c2e0d6d49fffe54928d53f9418cf10b7 diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index 77b544efbc..c21d2f3926 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest { } type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } interface LspProvider { @@ -77,7 +77,7 @@ interface LspService { } ``` -Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`. The seam exposes no protocol types, process or document controls, or generic request escape hatch. +Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. `dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index dad8160f0b..9556ead6c2 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest { } type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } interface LspProvider { @@ -77,7 +77,7 @@ interface LspService { } ``` -映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 +映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 `dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 6eea8aec27..795fe0dbff 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -41,7 +41,7 @@ export class LspConnection { private readonly decoder: MessageDecoder private readonly pending = new Map() private nextId = 1 - private stderr = '' + private stderr = Buffer.alloc(0) private closeReason: Error | undefined /** Set once the process has fully exited; the instance awaits it during teardown. */ readonly closed: Promise @@ -90,7 +90,7 @@ export class LspConnection { /** The retained stderr tail, for diagnostics on a failed server. */ get stderrTail(): string { - return this.stderr + return this.stderr.toString('utf8') } /** @@ -199,7 +199,17 @@ export class LspConnection { private onStderr(chunk: Buffer): void { // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just // before it exits, so the final bounded segment is the useful one. - this.stderr = (this.stderr + chunk.toString('utf8')).slice(-this.spec.maxStderrBytes) + const cap = this.spec.maxStderrBytes + if (chunk.length >= cap) { + // Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer. + this.stderr = Buffer.from(chunk.subarray(chunk.length - cap)) + return + } + const retainedBytes = Math.min(this.stderr.length, cap - chunk.length) + this.stderr = Buffer.concat([ + this.stderr.subarray(this.stderr.length - retainedBytes), + chunk, + ], retainedBytes + chunk.length) } private dispatch(message: unknown): void { @@ -246,7 +256,7 @@ export class LspConnection { /** The exit-close error message, appending the retained stderr tail when the server wrote any. */ private exitMessage(): string { - const tail = this.stderr.trim() + const tail = this.stderrTail.trim() return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` } diff --git a/packages/lsp/lsp-local/src/framing.ts b/packages/lsp/lsp-local/src/framing.ts index 8720247272..bfa6b362b5 100644 --- a/packages/lsp/lsp-local/src/framing.ts +++ b/packages/lsp/lsp-local/src/framing.ts @@ -64,6 +64,9 @@ export class MessageDecoder { } return { ready: false } } + if (separator > MAX_HEADER_BYTES) { + throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`) + } const headerText = this.buffer.toString('ascii', 0, separator) const contentLength = parseContentLength(headerText) if (contentLength > this.maxMessageBytes) { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 19d29e3618..f1d5408f5c 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-lsp-local */ -import { accessSync, constants } from 'node:fs' +import { accessSync, constants, statSync } from 'node:fs' import { delimiter, isAbsolute, join } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' @@ -178,19 +178,23 @@ class LocalLspProvider implements LspProvider { // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. /* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */ if (this.isDisposed()) throw new Error('lsp-local provider is disposed') - const instance = await this.instanceFor(workspace) + // Re-check cancellation too: an abort during the canonicalize/read awaits must not go on to spawn + // (or pool) a server solely for an operation the caller already gave up on. + if (signal?.aborted) throw abortError(signal) + let instance = await this.instanceFor(workspace) + // A pooled server that exited while idle resolves to a dead instance: evict it and create a fresh + // one before dispatch, so this query does not have to fail on a closed connection first. One retry + // suffices — the replacement was just constructed and has not been used. + if (instance.dead) { + await this.evictIfCurrent(workspace, instance) + instance = await this.instanceFor(workspace) + } try { return await instance.query(request, source, signal) } finally { // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, // but only if the slot still holds THIS instance (a concurrent replacement must survive). - if (instance.dead) { - const slot = this.instances.get(workspace) - /* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */ - if (slot !== undefined && (await settledInstance(slot)) === instance) { - this.instances.delete(workspace) - } - } + if (instance.dead) await this.evictIfCurrent(workspace, instance) } } @@ -208,6 +212,15 @@ class LocalLspProvider implements LspProvider { return created } + /** Drop the slot for `workspace` iff it still resolves to `instance` (a concurrent replacement survives). */ + private async evictIfCurrent(workspace: string, instance: LspInstance): Promise { + const slot = this.instances.get(workspace) + /* v8 ignore next -- the slot-undefined/mismatch arm needs a concurrent eviction of the same slot; defensive. */ + if (slot !== undefined && (await settledInstance(slot)) === instance) { + this.instances.delete(workspace) + } + } + private createInstance(workspace: string): LspInstance { const spec: InstanceSpec = { command: this.executable, @@ -265,7 +278,7 @@ function buildChildEnv(extra: Record): Record { function resolveExecutable(command: string, childEnv: Record): string { if (isAbsolute(command)) { // Verify an absolute command too, so an unavailable one fails at load, not on the first query. - if (!isExecutableSync(command)) { + if (!isExecutableFileSync(command)) { throw new Error(`lsp-local: command "${command}" is not an executable file`) } return command @@ -275,14 +288,15 @@ function resolveExecutable(command: string, childEnv: Record): s for (const dir of pathValue.split(delimiter)) { if (dir === '') continue const candidate = join(dir, command) - if (isExecutableSync(candidate)) return candidate + if (isExecutableFileSync(candidate)) return candidate } throw new Error(`lsp-local: command "${command}" was not found on PATH`) } -/** Synchronous executable check used only at load-time resolution. */ -function isExecutableSync(path: string): boolean { +/** Synchronous regular-file and executable check used only at load-time resolution. */ +function isExecutableFileSync(path: string): boolean { try { + if (!statSync(path).isFile()) return false accessSync(path, constants.X_OK) return true } catch { diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index cd73d5cb8d..8bf2e48c5b 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -169,7 +169,7 @@ export class LspInstance { */ private abortable(work: Promise, signal: AbortSignal | undefined): Promise { if (signal === undefined) return work - /* v8 ignore next -- runQuery checks signal.aborted before each abortable() call, so it is not already aborted here; defensive. */ + /* v8 ignore next -- callers either pre-check the signal or pass a freshly armed teardown deadline. */ if (signal.aborted) return Promise.reject(abortError(signal)) return new Promise((resolve, reject) => { const onAbort = (): void => { reject(abortError(signal)) } @@ -232,7 +232,10 @@ export class LspInstance { if (operation === 'hover') { return { kind: 'hover', hover: normalizeHover(payload) } } - return { kind: 'locations', locations: normalizeLocations(payload) } + // `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning), + // and every `file:` location URI is relative to it — so it is the root a caller must relativize + // display paths against, not the request's possibly-symlinked workspaceRoot. + return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd } } private answerServerRequest(method: string, params: unknown): Promise { @@ -271,24 +274,18 @@ export class LspInstance { try { using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') await this.gracefulShutdown(shutdownDeadline.signal) + return } catch { // Graceful shutdown failed or timed out: fall through to signal escalation. } await this.forceTerminate() } - /** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */ + /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */ private async gracefulShutdown(signal: AbortSignal): Promise { - const shutdown = this.connection.request('shutdown', null) - await Promise.race([ - shutdown, - new Promise((_, reject) => { - /* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */ - if (signal.aborted) { reject(abortError(signal)); return } - signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true }) - }), - ]) + await this.abortable(this.connection.request('shutdown', null), signal) this.connection.notify('exit', null) + await this.abortable(this.connection.closed, signal) } /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */ diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index 0b33953ba1..cea1f0d189 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -55,7 +55,6 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) console.log(JSON.stringify(result)) await ctx.fiber.dispose() - process.exit(0) ` const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index baf6fde05f..25ac9f2971 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -190,6 +190,13 @@ describe('LspConnection edge behavior', () => { expect(conn.stderrTail.length).toBe(100) }) + it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => { + const conn = connectScript('process.stderr.write("😀😀")', 4) + await conn.closed + expect(conn.stderrTail).toBe('😀') + expect(Buffer.byteLength(conn.stderrTail)).toBe(4) + }) + it('rejects with a fallback message when the error response has no message string', async () => { const script = 'let b=Buffer.alloc(0);' + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 104b223794..1654cb820d 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -10,6 +10,9 @@ * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request. * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests). * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). + * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request, + * simulating a server that dies while idle so the pool holds a dead instance (eviction test). + * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr. @@ -19,11 +22,16 @@ * Run: node --import tsx fixture-server.ts */ +import { appendFileSync } from 'node:fs' + const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {} const hang = process.env.LSP_FAKE_HANG === '1' const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' +const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' +const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) +const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' const onOpen = process.env.LSP_FAKE_ON_OPEN const errorReply = process.env.LSP_FAKE_ERROR === '1' @@ -32,6 +40,11 @@ const garbage = process.env.LSP_FAKE_GARBAGE === '1' let serverRequestId = 10_000 const pendingServerRequests = new Map() +process.on('SIGTERM', () => { + markExit('TERM') + process.exit(0) +}) + function resultFor(method: string): unknown { switch (method) { case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) @@ -98,6 +111,15 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul return } if (method === 'exit') { + markExit('EXIT') + if (exitDelayMs > 0) { + setTimeout(() => { + markExit('CLEAN') + process.exit(0) + }, exitDelayMs) + return + } + markExit('CLEAN') process.exit(0) } if (method === 'textDocument/didOpen') { @@ -110,12 +132,20 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (hang) return if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return } send({ id, result: resultFor(method) }) + // Simulate an idle death: answer this request, then exit before the next one arrives so the pool + // is left holding a dead instance. + if (exitAfterReply) setTimeout(() => process.exit(0), 20) return } // Unknown request with an id: answer null so the client never stalls. if (id !== undefined) send({ id, result: null }) } +/** Append one teardown event when the fixture is configured to expose process ordering. */ +function markExit(event: string): void { + if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`) +} + /** Emit a server→client request and log the client's reply to stderr for the test to assert. */ function emitServerRequest(kind: string): void { if (kind === 'notification') { diff --git a/packages/lsp/lsp-local/tests/framing.spec.ts b/packages/lsp/lsp-local/tests/framing.spec.ts index 66bca07f10..a197b2c0ca 100644 --- a/packages/lsp/lsp-local/tests/framing.spec.ts +++ b/packages/lsp/lsp-local/tests/framing.spec.ts @@ -69,6 +69,12 @@ describe('MessageDecoder', () => { expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/) }) + it('rejects an oversized header block that includes its terminator', () => { + const decoder = new MessageDecoder(1_000) + const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii') + expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/) + }) + it('rejects a non-JSON body', () => { const decoder = new MessageDecoder(1_000) expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 83c4bdfe77..52fc751754 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' @@ -96,17 +96,17 @@ describe('LspInstance server-request handling', () => { it('accepts a lifecycle client/registerCapability request', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects a workspace/applyEdit request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects an unknown server request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) }) @@ -195,6 +195,18 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { + it('lets a server finish protocol exit before signal escalation', async () => { + const marker = join(root, 'graceful-exit.log') + const instance = makeInstance({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_EXIT_DELAY_MS: '75', + LSP_FAKE_EXIT_MARKER: marker, + }, { shutdownTimeoutMs: 500 }) + await run(instance, 'definition') + await instance.dispose() + expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') + }) + it('is idempotent — a second dispose awaits close without error', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) await run(instance, 'definition') diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index bf624a1c56..78f1a8e419 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -59,6 +59,7 @@ describe('lsp-local end to end over a fake server', () => { expect(result).toEqual({ kind: 'locations', locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], + resolvedWorkspaceRoot: ws, }) await ctx.fiber.dispose() }) @@ -89,7 +90,7 @@ describe('lsp-local end to end over a fake server', () => { it('returns an empty locations result for a null definition', async () => { const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -123,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => { it('accepts openClose options sync', async () => { const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -195,6 +196,31 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) + it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => { + // The first query succeeds, then the server exits before the second arrives, leaving a dead + // instance in the pool. The next query must evict-and-replace it and still succeed, rather than + // failing once on the closed connection first. + const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + // Wait past the fixture's post-reply exit so the pooled instance is observably dead. + await new Promise(resolve => setTimeout(resolve, 60)) + expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('does not spawn a server when the signal aborts during source read', async () => { + // Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource + // are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance. + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + const pending = ctx.lsp.query(query('definition'), controller.signal) + controller.abort(new Error('mid-read cancel')) + await expect(pending).rejects.toThrow(/mid-read cancel/) + // A subsequent live query still works, proving no half-created instance poisoned the pool. + expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await ctx.fiber.dispose() + }) + it('runs distinct workspaces in parallel instances', async () => { const ws2 = join(root, 'ws2') await mkdir(ws2) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 20978df8d5..53f9a88369 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -102,4 +102,16 @@ describe('lsp-local provider resolution', () => { })).rejects.toThrow(/is not an executable file/) await ctx.fiber.dispose() }) + + it('rejects an executable directory as a command at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + providerId: 'abs-directory', + command: ws, + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + })).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) }) diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index eac45e9f51..8e51d0653a 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -25,7 +25,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner ## Vocabulary -`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. +`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. ## Model Experience diff --git a/packages/lsp/lsp/src/types.ts b/packages/lsp/lsp/src/types.ts index 0a2d73fac1..d1ae71dccd 100644 --- a/packages/lsp/lsp/src/types.ts +++ b/packages/lsp/lsp/src/types.ts @@ -76,9 +76,14 @@ export interface LspHover { * The closed result union. Navigation operations (`definition`, `references`, `implementation`) * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` * to exhaustiveness so a new arm breaks compilation until handled. + * + * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the + * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that + * relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`; + * otherwise a symlinked workspace misclassifies in-workspace results as external. */ export type LspQueryResult = - | { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } | { readonly kind: 'hover'; readonly hover: LspHover | null } /** diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts index 6746a05dc3..8561891df1 100644 --- a/packages/lsp/lsp/tests/lsp.spec.ts +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -13,7 +13,7 @@ import Lsp, { function makeProvider( id: string, extensionToLanguage: Record, - result: LspQueryResult = { kind: 'locations', locations: [] }, + result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }, ): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } { const seen: LspProviderQuery[] = [] const seenSignals: (AbortSignal | undefined)[] = [] @@ -63,7 +63,7 @@ describe('Lsp registration', () => { const provider = makeProvider('ts', { '.ts': 'typescript' }) const dispose = lsp.registerProvider(provider) - await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' }) dispose() @@ -148,7 +148,7 @@ describe('Lsp registration', () => { const py = makeProvider('py', { '.py': 'python' }) lsp.registerProvider(ts) lsp.registerProvider(py) - await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover) }) @@ -172,7 +172,7 @@ describe('Lsp registration', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) }, { inject: ['lsp'] })) - await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) await fiber.dispose() await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) }) diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index fdbd89d96b..179405c71e 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In `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. +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 relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; 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 diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index 1ec48a3d74..3a37b7b6ed 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -113,7 +113,10 @@ export function apply(ctx: Context, config: Config): void { }, exec.signal) switch (result.kind) { case 'locations': - return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }] + // Relativize against the provider's canonical workspace root (which its file: URIs are + // relative to), not the session cwd: a symlinked cwd would otherwise misclassify every + // in-workspace location as external and render it as an absolute path. + return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations) }] case 'hover': return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }] } diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 9cd48ed87f..577fa07396 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -51,6 +51,7 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') { const okLocations: LspQueryResult = { kind: 'locations', locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], + resolvedWorkspaceRoot: '/ws', } describe('tool-lsp registration', () => { @@ -107,6 +108,21 @@ describe('tool-lsp execution', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) }) + it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => { + // A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's + // location URIs are under. Relativizing against the alias would misclassify the location as + // external and print an absolute path; the tool must use resolvedWorkspaceRoot. + const provider = stubProvider(() => ({ + kind: 'locations', + locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], + resolvedWorkspaceRoot: '/real/ws', + })) + const { ctx } = await mount(provider) + const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/alias') + expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' }) + 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') From a63c55eb0053cadcea8a6987a82d8e367ebda766 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 17:31:20 +0800 Subject: [PATCH 09/15] refactor(lsp): configure local servers together --- docs/config-catalog.md | 12 +- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 4 +- .../2026-07-15-lsp-capability-seam.zh.md | 4 +- packages/lsp/README.md | 2 +- packages/lsp/lsp-local/README.md | 14 ++- packages/lsp/lsp-local/src/index.ts | 105 +++++++++++------- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 13 ++- .../lsp/lsp-local/tests/lifecycle.spec.ts | 49 ++++++-- packages/lsp/lsp-local/tests/provider.spec.ts | 79 ++++++++++--- .../lsp-local/tests/typescript-server.e2e.ts | 11 +- packages/lsp/lsp/README.md | 2 +- .../lsp/tool-lsp/tests/integration.spec.ts | 15 ++- 13 files changed, 213 insertions(+), 101 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b6b798b124..636713146c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -424,10 +424,14 @@ Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm Requires: `lsp` ```ts config-catalog -/** Plugin configuration: one server command plus its extension mapping and host bounds. */ +/** Plugin configuration: provider id → local language-server configuration. */ export interface Config { - /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ - providerId: string + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} + +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ @@ -453,7 +457,7 @@ export interface Config { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:59`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 69c509bae6..f063dae8cf 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: c21d2f3926b5b465f4ccf48f1bf954c2ae604e12 -2026-07-15-lsp-capability-seam.zh.md: 9556ead6c2e0d6d49fffe54928d53f9418cf10b7 +2026-07-15-lsp-capability-seam.md: 500d861f60bcd238d31defaa90a3e2495a05e767 +2026-07-15-lsp-capability-seam.zh.md: b181987f707563e3115e64313250859a4238c4ee diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index c21d2f3926..500d861f60 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -17,7 +17,7 @@ Many language servers behave best when the queried document is opened with curre Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: 1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors. -2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. Multiple plugin instances may register different server commands and extension-to-language-id mappings. +2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping. 3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation. `dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. @@ -79,7 +79,7 @@ interface LspService { Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. -`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. +`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. ## Model-facing contract diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 9556ead6c2..b181987f70 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -17,7 +17,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: 1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 -2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。 +2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。 3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACP(Agent Client Protocol)展示。 `dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 @@ -79,7 +79,7 @@ interface LspService { 映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 -`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 +`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 ## 面向模型的契约 diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 57a1f6b8d9..f57eed8ae0 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -5,7 +5,7 @@ The language-server capability seam: an abstract LSP interface, a generic stdio | Package | Role | ctx key | |---|---|---| | `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` | -| `lsp-local/` | Generic stdio language-server provider (spawn, JSON-RPC, transient-open queries) | (registers on `ctx.lsp`) | +| `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) | | `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 1424f27835..75b0595626 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -1,21 +1,23 @@ # @deepseek-ai/dsh-lsp-local -A **generic stdio language-server provider** for `ctx.lsp`. One plugin instance configures one server command and its extension-to-language-id map; load multiple instances for multiple servers. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in composition plugins or `cordis.yml` overlays. +A **generic local stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays. Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). ## What it does -- Lazily single-flights one server process per `(provider id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration -| Key | Default | Meaning | +The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape: + +| Server key | Default | Meaning | |---|---|---| -| `providerId` | (required) | Stable provider id reserved on `ctx.lsp` with the extensions. | | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | | `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | @@ -28,7 +30,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | | `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. | -The executable is resolved at load (after credential scrubbing); a missing command fails before registration. The process itself launches lazily on the first matching query. +`servers` must contain at least one entry, and every id must be non-empty. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. ## Protocol behavior @@ -46,4 +48,4 @@ Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized re - **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. - **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. -- **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal. +- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index f1d5408f5c..07e1cbed5a 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -1,10 +1,10 @@ /** - * Generic stdio language-server provider for `ctx.lsp`. One plugin instance configures one server - * command and its extension→language-id map; load multiple instances for multiple servers. The - * provider lazily single-flights one server process per `(provider id, canonical workspace - * realpath)`, serves transient-open queries through it, and evicts a crashed process so a later - * query can replace it. It reads sources through Node APIs in the host namespace (not `ctx.fs`) and - * trusts its configured server — no sandbox confinement. + * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table + * of server commands and registers one isolated provider for each entry. Every provider lazily + * single-flights one server process per canonical workspace realpath, serves transient-open queries + * through it, and evicts a crashed process so a later query can replace it. Providers read sources + * through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no + * sandbox confinement. * * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal * unregisters from `ctx.lsp` and tears down every live server. @@ -55,10 +55,8 @@ const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000 const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000 const DEFAULT_KILL_GRACE_MS = 2_000 -/** Plugin configuration: one server command plus its extension mapping and host bounds. */ -export interface Config { - /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ - providerId: string +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ @@ -83,11 +81,16 @@ export interface Config { killGraceMs?: number } -/** The resolved config after schemastery fills every default; the provider reads this shape. */ -type ResolvedConfig = Required +/** Plugin configuration: provider id → local language-server configuration. */ +export interface Config { + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} -export const Config: z = z.object({ - providerId: z.string().required(), +/** One server config after schemastery fills every default. */ +type ResolvedServerConfig = Required + +const LspLocalServerConfig: z = z.object({ command: z.string().required(), args: z.array(String).default([]), env: z.dict(String).default({}), @@ -101,43 +104,66 @@ export const Config: z = z.object({ killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS), }) +export const Config: z = z.object({ + servers: z.dict(LspLocalServerConfig).required(), +}) + /** - * Register a generic stdio LSP provider. Resolves the executable at load (after credential - * scrubbing) and fails before registration when it is unavailable; the process itself launches - * lazily on the first matching query. + * Register the configured stdio LSP providers. Resolves every executable at load (after credential + * scrubbing) before publishing any provider; each process launches lazily on its first matching + * query. * @param ctx - the plugin context (must inject `lsp`). * @param config - the resolved plugin configuration (schemastery has filled every default). */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const entries = Object.entries(config.servers) + if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server') + + // Resolve every server-local setting before registration so a bad later command or bound cannot + // publish an earlier provider. Registry-level mapping conflicts are rolled back below. + const providers = entries.map(([providerId, rawConfig]) => { + if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings') + const resolved = rawConfig as ResolvedServerConfig + validateServerConfig(providerId, resolved) + const childEnv = buildChildEnv(resolved.env) + const executable = resolveExecutable(resolved.command, childEnv) + return new LocalLspProvider(providerId, resolved, childEnv, executable) + }) + + ctx.effect(() => { + const disposers: Array<() => void> = [] + try { + for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider)) + } catch (error) { + for (const dispose of disposers.reverse()) dispose() + throw error + } + return async () => { + // Remove every route before process teardown so no new query can enter a draining provider. + for (const dispose of disposers.reverse()) dispose() + await Promise.all(providers.map(provider => provider.disposeAll())) + } + }, 'lsp-local.registerProviders') +} + +/** Validate one resolved server entry before any provider in the table is registered. */ +function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void { // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. - assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) - assertPositiveInteger('killGraceMs', resolved.killGraceMs) + assertPositiveInteger(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveInteger(providerId, 'killGraceMs', resolved.killGraceMs) // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad // document cap fails later in the read path instead of at load. - assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes) - assertPositiveInteger('maxMessageBytes', resolved.maxMessageBytes) - assertPositiveInteger('maxDocumentBytes', resolved.maxDocumentBytes) - const childEnv = buildChildEnv(resolved.env) - // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. - const executable = resolveExecutable(resolved.command, childEnv) - - const provider = new LocalLspProvider(resolved, childEnv, executable) - ctx.effect(() => { - const dispose = ctx.lsp.registerProvider(provider) - return async () => { - dispose() - await provider.disposeAll() - } - }, 'lsp-local.registerProvider') + assertPositiveInteger(providerId, 'maxStderrBytes', resolved.maxStderrBytes) + assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes) + assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes) } /** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */ -function assertPositiveInteger(name: string, value: number): void { +function assertPositiveInteger(providerId: string, name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { - throw new Error(`lsp-local: ${name} must be a positive integer`) + throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer`) } } @@ -150,11 +176,12 @@ class LocalLspProvider implements LspProvider { private disposed = false constructor( - private readonly config: ResolvedConfig, + providerId: string, + private readonly config: ResolvedServerConfig, private readonly childEnv: Record, private readonly executable: string, ) { - this.id = LspProviderId(config.providerId) + this.id = LspProviderId(providerId) this.extensionToLanguage = config.extensionToLanguage } diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index cea1f0d189..799069c0fd 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -46,11 +46,14 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const ctx = new Context() await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'fake', - command: ${JSON.stringify(process.execPath)}, - args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], - env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, - extensionToLanguage: { '.ts': 'typescript' }, + servers: { + fake: { + command: ${JSON.stringify(process.execPath)}, + args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], + env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, }) const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) console.log(JSON.stringify(result)) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 78f1a8e419..3d06b7576b 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -8,7 +8,7 @@ import { Context } from 'cordis' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' -import type { Config } from '@deepseek-ai/dsh-lsp-local' +import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -28,17 +28,23 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */ -async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { - const ctx = new Context() - await ctx.plugin(Lsp) - await ctx.plugin(LspLocal, { - providerId: 'fake', +/** One fake stdio server entry with optional behavior and host-bound overrides. */ +function fakeServer(fakeEnv: Record = {}, overrides: Partial = {}): LspLocalServerConfig { + return { command: process.execPath, args: ['--import', tsxLoader, fixtureServer], env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv }, extensionToLanguage: { '.ts': 'typescript' }, ...overrides, + } +} + +/** Mount the real seam + lsp-local plugin driving one fake server. */ +async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { fake: fakeServer(fakeEnv, overrides) }, }) return ctx } @@ -53,6 +59,24 @@ function locationJson(line: number): unknown { } describe('lsp-local end to end over a fake server', () => { + it('routes different extensions to independent configured servers', async () => { + await writeFile(join(ws, 'a.py'), 'x = 1\n') + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { + typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }), + python: fakeServer( + { LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) }, + { extensionToLanguage: { '.py': 'python' } }, + ), + }, + }) + expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } }) + expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } }) + await ctx.fiber.dispose() + }) + it('resolves definition to normalized locations', async () => { const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) const result = await ctx.lsp.query(query('definition')) @@ -245,10 +269,13 @@ describe('lsp-local end to end over a fake server', () => { const ctx = new Context() await ctx.plugin(Lsp) await expect(ctx.plugin(LspLocal, { - providerId: 'missing', - command: 'definitely-not-a-real-lsp-binary-xyz', - args: [], - extensionToLanguage: { '.ts': 'typescript' }, + servers: { + missing: { + command: 'definitely-not-a-real-lsp-binary-xyz', + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, })).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() }) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 53f9a88369..65a22bfb5b 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' let root: string let ws: string @@ -24,6 +25,11 @@ function query(): LspQueryRequest { return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } } +/** Wrap one server entry in the plugin's named server table. */ +function config(providerId: string, server: LspLocalServerConfig): Config { + return { servers: { [providerId]: server } } +} + describe('lsp-local provider resolution', () => { it('resolves a bare command on the child PATH and registers the provider', async () => { // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. @@ -35,26 +41,24 @@ describe('lsp-local provider resolution', () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'onpath', + await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], env: { PATH: bin }, extensionToLanguage: { '.ts': 'typescript' }, - })).resolves.toBeDefined() + }))).resolves.toBeDefined() await ctx.fiber.dispose() }) it('skips empty PATH segments and fails when the command is absent', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'nope', + await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], env: { PATH: `::${join(root, 'empty')}` }, extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/was not found on PATH/) + }))).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() }) @@ -64,12 +68,11 @@ describe('lsp-local provider resolution', () => { await ctx.plugin(Lsp) // Grab the provider instance by registering, then dispose the whole plugin fiber. const lsp = ctx.lsp - const fiber = await ctx.plugin(LspLocal, { - providerId: 'disp', + const fiber = await ctx.plugin(LspLocal, config('disp', { command: process.execPath, args: ['-e', 'setInterval(()=>{},1000)'], extensionToLanguage: { '.ts': 'typescript' }, - }) + })) await fiber.dispose() // After disposal the provider unregistered from the seam, so selection fails as unavailable. await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) @@ -79,13 +82,12 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive teardown budget at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'bad-budget', + await expect(ctx.plugin(LspLocal, config('bad-budget', { command: process.execPath, args: ['-e', ''], extensionToLanguage: { '.ts': 'typescript' }, killGraceMs: 0, - })).rejects.toThrow(/killGraceMs must be a positive integer/) + }))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/) await ctx.fiber.dispose() }) @@ -94,24 +96,65 @@ describe('lsp-local provider resolution', () => { await writeFile(notExe, 'plain text, not executable') const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'abs-bad', + await expect(ctx.plugin(LspLocal, config('abs-bad', { command: notExe, args: [], extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/is not an executable file/) + }))).rejects.toThrow(/is not an executable file/) await ctx.fiber.dispose() }) it('rejects an executable directory as a command at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'abs-directory', + await expect(ctx.plugin(LspLocal, config('abs-directory', { command: ws, args: [], extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/is not an executable file/) + }))).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server table at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server id at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('', { + command: process.execPath, + extensionToLanguage: { '.ts': 'typescript' }, + }))).rejects.toThrow(/server ids must be non-empty strings/) + await ctx.fiber.dispose() + }) + + it('resolves every executable before publishing any provider', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } }, + }, + })).rejects.toThrow(/was not found on PATH/) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) + + it('rolls back earlier registrations when a later server conflicts', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + }, + })).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) await ctx.fiber.dispose() }) }) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index ca4df620d3..300ce7d318 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -53,10 +53,13 @@ beforeAll(async () => { ctx = new Context() await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'typescript', - command: serverBin, - args: ['--stdio'], - extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + servers: { + typescript: { + command: serverBin, + args: ['--stdio'], + extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + }, + }, }) }, 60_000) diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index 8e51d0653a..8923523e05 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -7,7 +7,7 @@ This package is the interface third of the LSP capability: | Package | Role | |---|---| | `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy | -| `@deepseek-ai/dsh-lsp-local` | a generic stdio language-server provider | +| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers | | `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index f2e8d1c46a..74d9c6ae77 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -52,12 +52,15 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { 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, + servers: { + 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 } : {}) From e92ec69f32058ff10bede65f43e6452575226ce3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 16 Jul 2026 17:55:07 +0800 Subject: [PATCH 10/15] refactor(lsp): drop redundant side-effect type import The value import of LspProviderId already pulls in the cordis module augmentation for ctx.lsp, so the separate `import type {}` is dead. --- docs/config-catalog.md | 2 +- packages/lsp/lsp-local/src/index.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 636713146c..ba2476b1a5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -457,7 +457,7 @@ export interface LspLocalServerConfig { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:83`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 07e1cbed5a..03ae5e9781 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -21,8 +21,6 @@ import type { LspProviderQuery, LspQueryResult, } from '@deepseek-ai/dsh-lsp' -// Side-effect type import: declaration-merges `ctx.lsp` onto Context. -import type {} from '@deepseek-ai/dsh-lsp' import { canonicalizeWorkspace, readHostSource } from './host.ts' import { abortError, LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' From a5c77325921120e326029d7f11446578b66687a8 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 17 Jul 2026 16:32:34 +0800 Subject: [PATCH 11/15] fix(lsp): address lifecycle review feedback --- examples/acp-agent/tests/acp.snapshot.ts | 2 + .../acp-agent/tests/lsp.cordis.snapshot.yml | 27 ++ examples/acp-agent/tests/lsp.cordis.yml | 23 ++ .../tests/snapshots/lsp-definition/input.json | 7 + .../snapshots/lsp-definition/session.jsonl | 23 ++ .../lsp-definition/stdout.golden.jsonl | 6 + .../lsp-definition/system-prompt.golden.md | 15 + .../lsp-definition/tool-schemas.golden.json | 282 ++++++++++++++++++ .../lsp-definition/workspace/lsp-server.mjs | 57 ++++ .../lsp-definition/workspace/subject.ts | 2 + knip.json | 1 + packages/lsp/lsp-local/src/connection.ts | 33 ++ packages/lsp/lsp-local/src/index.ts | 80 ++--- packages/lsp/lsp-local/src/instance.ts | 77 ++--- packages/lsp/lsp-local/tests/instance.spec.ts | 31 ++ .../lsp/tool-lsp/tests/integration.spec.ts | 8 +- packages/lsp/tool-lsp/tests/load-path.spec.ts | 8 +- 17 files changed, 585 insertions(+), 97 deletions(-) create mode 100644 examples/acp-agent/tests/lsp.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/lsp.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/input.json create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs create mode 100644 examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 0d29f7b5ce..15670aa995 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -28,6 +28,7 @@ const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) +const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -55,6 +56,7 @@ const SCENARIOS: Scenario[] = [ { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, { name: 'workspace-edit', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, { name: 'fs-read', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-write', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-edit', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml new file mode 100644 index 0000000000..4d24ead86d --- /dev/null +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -0,0 +1,27 @@ +# Keyless replay keeps the LSP composition intact and replaces only the model adapter. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: lsp + name: '@deepseek-ai/dsh-lsp' + - id: lsp-local + name: '@deepseek-ai/dsh-lsp-local' + config: + servers: + fixture: + command: !!js process.execPath + args: ['./lsp-server.mjs'] + extensionToLanguage: + '.ts': typescript + - id: tool-lsp + name: '@deepseek-ai/dsh-tool-lsp' + config: + maxLocations: 1 + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/tests/lsp.cordis.yml b/examples/acp-agent/tests/lsp.cordis.yml new file mode 100644 index 0000000000..f1eefa99af --- /dev/null +++ b/examples/acp-agent/tests/lsp.cordis.yml @@ -0,0 +1,23 @@ +# Exercise the model-facing LSP tool through the shipped ACP app and Loader entry path. +# The scenario workspace supplies the deterministic stdio server used by this test composition. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - insert: + - id: lsp + name: '@deepseek-ai/dsh-lsp' + - id: lsp-local + name: '@deepseek-ai/dsh-lsp-local' + config: + servers: + fixture: + command: !!js process.execPath + args: ['./lsp-server.mjs'] + extensionToLanguage: + '.ts': typescript + - id: tool-lsp + name: '@deepseek-ai/dsh-tool-lsp' + config: + maxLocations: 1 diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/input.json b/examples/acp-agent/tests/snapshots/lsp-definition/input.json new file mode 100644 index 0000000000..2b49f7d280 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl new file mode 100644 index 0000000000..e1a93752bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl new file mode 100644 index 0000000000..0abf55a261 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.golden.jsonl @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP definition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_lsp_definition","status":"completed","content":[{"type":"content","content":{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md new file mode 100644 index 0000000000..21a80508f7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.golden.md @@ -0,0 +1,15 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +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. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json new file mode 100644 index 0000000000..72aaa3bd7d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.golden.json @@ -0,0 +1,282 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "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": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "definition, references, implementation, or hover.", + "enum": [ + "definition", + "references", + "implementation", + "hover" + ] + }, + "file_path": { + "type": "string", + "description": "The source file to query, relative to the workspace or absolute." + }, + "line": { + "type": "number", + "description": "One-based line of the cursor." + }, + "character": { + "type": "number", + "description": "One-based UTF-16 column of the cursor." + } + }, + "required": [ + "operation", + "file_path", + "line", + "character" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + } + }, + "required": [ + "script", + "meta" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs new file mode 100644 index 0000000000..431b322e0b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs @@ -0,0 +1,57 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +let buffered = Buffer.alloc(0) + +function frame(message) { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message })) + return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]) +} + +function location(line) { + return { + uri: pathToFileURL(resolve('subject.ts')).href, + range: { start: { line, character: 6 }, end: { line, character: 12 } }, + } +} + +function handle(message) { + switch (message.method) { + case 'initialize': + process.stdout.write(frame({ + id: message.id, + result: { + capabilities: { + positionEncoding: 'utf-16', + textDocumentSync: 1, + definitionProvider: true, + }, + }, + })) + break + case 'textDocument/definition': + process.stdout.write(frame({ id: message.id, result: [location(0), location(1)] })) + break + case 'shutdown': + process.stdout.write(frame({ id: message.id, result: null })) + break + case 'exit': + process.exit(0) + } +} + +process.stdin.on('data', (chunk) => { + buffered = Buffer.concat([buffered, chunk]) + for (;;) { + const headerEnd = buffered.indexOf('\r\n\r\n') + if (headerEnd < 0) return + const match = /Content-Length: (\d+)/i.exec(buffered.toString('ascii', 0, headerEnd)) + if (match === null) throw new Error('missing Content-Length') + const length = Number(match[1]) + const bodyStart = headerEnd + 4 + if (buffered.length < bodyStart + length) return + const message = JSON.parse(buffered.toString('utf8', bodyStart, bodyStart + length)) + buffered = buffered.subarray(bodyStart + length) + handle(message) + } +}) diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts new file mode 100644 index 0000000000..6f3d62ca43 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts @@ -0,0 +1,2 @@ +export const answer = 42 +console.log(answer) diff --git a/knip.json b/knip.json index 76634b0b78..210aea5cae 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], + "ignore": ["examples/*/tests/snapshots/*/workspace/**/*"], "ignoreBinaries": ["bwrap", "sandbox-exec"], "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 795fe0dbff..e655877eb5 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -10,6 +10,7 @@ import type { ChildProcessByStdio } from 'node:child_process' import { spawn } from 'node:child_process' import type { Readable, Writable } from 'node:stream' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { encodeMessage, MessageDecoder } from './framing.ts' /** How to launch the server and answer its config requests. */ @@ -163,6 +164,19 @@ export class LspConnection { this.signalGroup('SIGKILL') } + /** + * Wait until the owned process group has no members. + * @param signal - optional bound for the wait. + * @returns `true` when the group exited, or `false` when the signal aborted first. + */ + async waitForProcessGroupExit(signal?: AbortSignal): Promise { + while (this.processGroupAlive()) { + if (signal?.aborted) return false + await yieldToEventLoop() + } + return true + } + /** * Signal the whole process group (negative pid) so helper processes are reached; fall back to the * direct child if the group send fails. Never throws — teardown races process exit. @@ -182,6 +196,25 @@ export class LspConnection { } } + /** Whether the detached process group still has at least one member. */ + private processGroupAlive(): boolean { + const pid = this.child.pid + /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ + if (pid === undefined) return false + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return this.child.exitCode === null && this.child.signalCode === null + /* v8 ignore stop */ + } + } + private onStdout(chunk: Buffer): void { let messages: unknown[] try { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 03ae5e9781..67e86da11c 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -169,8 +169,8 @@ function assertPositiveInteger(providerId: string, name: string, value: number): class LocalLspProvider implements LspProvider { readonly id: LspProviderId readonly extensionToLanguage: Readonly> - /** Single-flight map: canonical workspace realpath → the (pending) instance for it. */ - private readonly instances = new Map>() + /** One live instance per canonical workspace realpath. */ + private readonly instances = new Map() private disposed = false constructor( @@ -188,12 +188,17 @@ class LocalLspProvider implements LspProvider { return this.disposed } - async query(request: LspProviderQuery, signal?: AbortSignal): Promise { - /* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */ + /** Reject work that cannot publish or use a provider-owned instance. */ + private assertActive(signal?: AbortSignal): void { + /* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls + exercise the post-await check instead. */ if (this.isDisposed()) throw new Error('lsp-local provider is disposed') - // Honor an already-aborted signal before any host I/O so a canceled request neither reads nor - // spawns a server. if (signal?.aborted) throw abortError(signal) + } + + async query(request: LspProviderQuery, signal?: AbortSignal): Promise { + // Honor an already-aborted signal before host I/O so a canceled request never starts a server. + this.assertActive(signal) const workspace = await canonicalizeWorkspace(request.workspaceRoot) // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized // source must fail without leaving an idle process pooled (the pre-start rejection contract), and @@ -201,49 +206,37 @@ class LocalLspProvider implements LspProvider { const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes) // Re-check disposal after the awaits: disposeAll() may have snapshotted the instance map while we // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. - /* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */ - if (this.isDisposed()) throw new Error('lsp-local provider is disposed') - // Re-check cancellation too: an abort during the canonicalize/read awaits must not go on to spawn - // (or pool) a server solely for an operation the caller already gave up on. - if (signal?.aborted) throw abortError(signal) - let instance = await this.instanceFor(workspace) - // A pooled server that exited while idle resolves to a dead instance: evict it and create a fresh - // one before dispatch, so this query does not have to fail on a closed connection first. One retry - // suffices — the replacement was just constructed and has not been used. + this.assertActive(signal) + let instance = this.instanceFor(workspace) + // Eviction and replacement are synchronous so disposal cannot snapshot the pool between them and + // miss a newly spawned process. if (instance.dead) { - await this.evictIfCurrent(workspace, instance) - instance = await this.instanceFor(workspace) + this.evictIfCurrent(workspace, instance) + instance = this.instanceFor(workspace) } try { return await instance.query(request, source, signal) } finally { // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, // but only if the slot still holds THIS instance (a concurrent replacement must survive). - if (instance.dead) await this.evictIfCurrent(workspace, instance) + if (instance.dead) this.evictIfCurrent(workspace, instance) } } - /** Single-flight one instance per canonical workspace; a rejected creation clears the slot. */ - private instanceFor(workspace: string): Promise { + /** Return or synchronously publish the one instance for a canonical workspace. */ + private instanceFor(workspace: string): LspInstance { + this.assertActive() const existing = this.instances.get(workspace) if (existing !== undefined) return existing - const created = Promise.resolve().then(() => this.createInstance(workspace)) + const created = this.createInstance(workspace) this.instances.set(workspace, created) - /* v8 ignore next 3 -- createInstance (the LspInstance constructor) does not throw; spawn failures - surface asynchronously through the instance, so this creation-rejection cleanup is defensive. */ - created.catch(() => { - if (this.instances.get(workspace) === created) this.instances.delete(workspace) - }) return created } - /** Drop the slot for `workspace` iff it still resolves to `instance` (a concurrent replacement survives). */ - private async evictIfCurrent(workspace: string, instance: LspInstance): Promise { - const slot = this.instances.get(workspace) - /* v8 ignore next -- the slot-undefined/mismatch arm needs a concurrent eviction of the same slot; defensive. */ - if (slot !== undefined && (await settledInstance(slot)) === instance) { - this.instances.delete(workspace) - } + /** Drop the slot iff it still contains this instance. */ + private evictIfCurrent(workspace: string, instance: LspInstance): void { + /* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */ + if (this.instances.get(workspace) === instance) this.instances.delete(workspace) } private createInstance(workspace: string): LspInstance { @@ -265,26 +258,9 @@ class LocalLspProvider implements LspProvider { /** Dispose every live instance and block further queries. */ async disposeAll(): Promise { this.disposed = true - const pending = [...this.instances.values()] + const live = [...this.instances.values()] this.instances.clear() - await Promise.all(pending.map(async (entry) => { - try { - const instance = await entry - await instance.dispose() - } catch { - // A never-initialized instance already rejected; nothing to tear down. - } - })) - } -} - -/** Resolve a slot promise to its instance for identity comparison, tolerating a pending rejection. */ -async function settledInstance(slot: Promise): Promise { - try { - return await slot - } catch { - /* v8 ignore next -- a slot promise only rejects if createInstance throws, which it never does; defensive. */ - return undefined + await Promise.all(live.map(instance => instance.dispose())) } } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 8bf2e48c5b..fb67f6e777 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -48,6 +48,8 @@ export class LspInstance { /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ private queue: Promise = Promise.resolve() private disposed = false + /** The one teardown transaction shared by abort, failure, and explicit disposal. */ + private teardownPromise: Promise | undefined /** Set once the process closes, so the pool can synchronously skip a dead instance. */ private processClosed = false /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ @@ -116,9 +118,8 @@ export class LspInstance { await this.abortable(this.ready, signal) } catch (error) { if (!this.dead) { - this.disposed = true /* v8 ignore next -- ready rejects with an Error (abort reason or initialize failure); the String() fallback is defensive. */ - await this.tearDown(error instanceof Error ? error : new Error(String(error))) + await this.startTeardown(error instanceof Error ? error : new Error(String(error))) } throw error } @@ -155,8 +156,7 @@ export class LspInstance { listener, so a synchronous didClose write failure is a defensive path. */ // A close-write failure does not replace the settled result/error, but the instance can no // longer be trusted: invalidate it and await bounded process termination. - this.disposed = true - void this.tearDown(error instanceof Error ? error : new Error(String(error))) + void this.startTeardown(error instanceof Error ? error : new Error(String(error))) /* v8 ignore stop */ } } @@ -210,19 +210,20 @@ export class LspInstance { this.connection.cancel(requestId) // Wait, bounded, for the server to honor the cancellation. If it does not, the request is still // running: terminate the instance (disposal awaits process close) so nothing outlives the query. - using grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') - // `settled` is true if the request finished (either outcome) before the grace elapsed. - const settled = await Promise.race([ - send.then(markSettled, markSettled), - new Promise((resolve) => { - /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ - if (grace.signal.aborted) { resolve(false); return } - grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) - }), - ]) - if (!settled && !this.disposed) { - this.disposed = true - await this.tearDown(abortError(signal)) + const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') + try { + // `settled` is true if the request finished (either outcome) before the grace elapsed. + const settled = await Promise.race([ + send.then(markSettled, markSettled), + new Promise((resolve) => { + /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ + if (grace.signal.aborted) { resolve(false); return } + grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + ]) + if (!settled) await this.startTeardown(abortError(signal)) + } finally { + grace[Symbol.dispose]() } throw error } @@ -262,21 +263,24 @@ export class LspInstance { * process close so nothing outlives disposal. */ async dispose(): Promise { - if (this.disposed) { - await this.connection.closed - return - } + await this.startTeardown(new Error('LSP instance disposed')) + } + + /** Publish disposal once and make every caller await the same quiescence boundary. */ + private startTeardown(reason: Error): Promise { this.disposed = true - await this.tearDown(new Error('LSP instance disposed')) + this.teardownPromise ??= this.tearDown(reason) + return this.teardownPromise } private async tearDown(_reason: Error): Promise { + const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') try { - using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') await this.gracefulShutdown(shutdownDeadline.signal) - return } catch { - // Graceful shutdown failed or timed out: fall through to signal escalation. + // Graceful shutdown failed or timed out; process-group cleanup below remains authoritative. + } finally { + shutdownDeadline[Symbol.dispose]() } await this.forceTerminate() } @@ -288,20 +292,21 @@ export class LspInstance { await this.abortable(this.connection.closed, signal) } - /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */ + /** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */ private async forceTerminate(): Promise { this.connection.terminate() - using graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - const closedInTime = await Promise.race([ - this.connection.closed.then(() => true), - new Promise((resolve) => { - /* v8 ignore next -- the kill-grace deadline signal is freshly armed and not yet aborted here; defensive. */ - if (graceDeadline.signal.aborted) { resolve(false); return } - graceDeadline.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) - }), + const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') + let groupExited: boolean + try { + groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal) + } finally { + graceDeadline[Symbol.dispose]() + } + if (!groupExited) this.connection.kill() + await Promise.all([ + this.connection.closed, + this.connection.waitForProcessGroupExit(), ]) - if (!closedInTime) this.connection.kill() - await this.connection.closed } } diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 52fc751754..f46d72571b 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -236,6 +236,26 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) + it('awaits a surviving process-group helper on every concurrent dispose', async () => { + const marker = join(root, 'helper.pid') + const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' + const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' + + `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});` + + `writeFileSync(${JSON.stringify(marker)},String(helper.pid));` + + RESPONDING_SERVER + const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await run(instance, 'definition') + const helperPid = Number(await readFile(marker, 'utf8')) + try { + const first = instance.dispose() + await instance.dispose() + expect(processAlive(helperPid)).toBe(false) + await first + } finally { + if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL') + } + }) + it('carries a non-Error abort reason as a generic aborted error', async () => { const instance = makeInstance({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() @@ -245,3 +265,14 @@ describe('LspInstance disposal', () => { await expect(pending).rejects.toThrow(/aborted/) }) }) + +/** Probe a pid without changing its state. */ +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 74d9c6ae77..5c98d9fff0 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -12,10 +12,8 @@ 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. + * Focused in-process integration of the model-facing tool, seam, local provider, and timeout policy. + * The `lsp-definition` ACP snapshot owns the shipped Loader/app entry path. */ let root: string @@ -77,7 +75,7 @@ function call(ctx: Context, args: unknown) { }) } -describe('tool-lsp real composition', () => { +describe('tool-lsp integration', () => { 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 }) diff --git a/packages/lsp/tool-lsp/tests/load-path.spec.ts b/packages/lsp/tool-lsp/tests/load-path.spec.ts index 13dbaa7b78..7b3ec0f241 100644 --- a/packages/lsp/tool-lsp/tests/load-path.spec.ts +++ b/packages/lsp/tool-lsp/tests/load-path.spec.ts @@ -1,15 +1,15 @@ /** - * Real-load-path guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a + * Loader export-shape 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. + * bare `apply`, dropping `inject` (postmortem 0001). This verifies the namespace survives + * `Loader.prototype.unwrapExports`; the `lsp-definition` ACP snapshot owns full app composition. */ 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', () => { +describe('dsh-tool-lsp Loader export-shape guard', () => { it('has no default export and keeps name/inject/Config through unwrapExports', () => { expect('default' in toolLsp).toBe(false) From 311503dfc68f7ac68e6d45cce31b3e2c9c54d22a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 20 Jul 2026 13:59:02 +0800 Subject: [PATCH 12/15] Declare LSP packages for example configs --- examples/package.json | 3 +++ pnpm-lock.yaml | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/examples/package.json b/examples/package.json index 3a5f90d30e..a7f31153e1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -22,6 +22,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-lsp": "workspace:*", + "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -38,6 +40,7 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca5a81da13..7824512d72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,12 @@ importers: '@deepseek-ai/dsh-llm-replay': specifier: workspace:* version: link:../packages/support/llm-replay + '@deepseek-ai/dsh-lsp': + specifier: workspace:* + version: link:../packages/lsp/lsp + '@deepseek-ai/dsh-lsp-local': + specifier: workspace:* + version: link:../packages/lsp/lsp-local '@deepseek-ai/dsh-permission': specifier: workspace:* version: link:../packages/ui/permission @@ -191,6 +197,9 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:* version: link:../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-lsp': + specifier: workspace:* + version: link:../packages/lsp/tool-lsp '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent From be84a73f5662756bbb692355e9b6888f37a625b6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 20 Jul 2026 14:40:18 +0800 Subject: [PATCH 13/15] Stabilize process-group coverage across POSIX --- packages/lsp/lsp-local/src/connection.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index e655877eb5..6acb321e90 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -206,6 +206,8 @@ export class LspConnection { return true } catch (error) { const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes + whether lifecycle tests observe this branch platform-dependent. */ if (code === 'ESRCH') return false /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ From 2fd995bf3c838197b838b592cdc1133d3396f8ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:29:40 +0800 Subject: [PATCH 14/15] fix(lsp): align operations and harden lifecycle --- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 26 ++--- .../2026-07-15-lsp-capability-seam.zh.md | 28 +++--- AGENTS.md | 1 + docs/config-catalog.md | 10 +- docs/core-data-structures/lsp.md | 17 ++-- docs/module-graph.md | 3 +- docs/tool-catalog.md | 10 +- .../acp-agent/tests/lsp.cordis.snapshot.yml | 2 + examples/acp-agent/tests/lsp.cordis.yml | 2 + .../snapshots/lsp-definition/session.jsonl | 8 +- .../lsp-definition/stdout.expected.jsonl | 2 +- .../lsp-definition/system-prompt.expected.md | 2 +- .../lsp-definition/tool-schemas.expected.json | 10 +- knip.json | 2 +- packages/lsp/README.md | 2 +- packages/lsp/lsp-local/README.md | 10 +- packages/lsp/lsp-local/package.json | 2 +- packages/lsp/lsp-local/src/abort.ts | 48 +++++++++ packages/lsp/lsp-local/src/connection.ts | 66 ++++++++----- packages/lsp/lsp-local/src/host.ts | 28 +++++- packages/lsp/lsp-local/src/index.ts | 90 +++++++++++------ packages/lsp/lsp-local/src/instance.ts | 82 ++++++--------- packages/lsp/lsp-local/src/translate.ts | 67 +++++++++---- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 8 +- .../lsp/lsp-local/tests/connection.spec.ts | 21 ++-- .../lsp/lsp-local/tests/fixture-server.ts | 33 +++++-- packages/lsp/lsp-local/tests/host.spec.ts | 18 ++++ packages/lsp/lsp-local/tests/instance.spec.ts | 63 +++++++----- .../lsp/lsp-local/tests/lifecycle.spec.ts | 99 +++++++++++++------ packages/lsp/lsp-local/tests/provider.spec.ts | 27 ++++- .../lsp/lsp-local/tests/translate.spec.ts | 44 ++++++--- .../lsp-local/tests/typescript-server.e2e.ts | 6 +- packages/lsp/lsp/README.md | 4 +- packages/lsp/lsp/src/index.ts | 8 +- packages/lsp/lsp/src/types.ts | 13 +-- packages/lsp/lsp/tests/lsp.spec.ts | 2 +- packages/lsp/tool-lsp/README.md | 10 +- packages/lsp/tool-lsp/package.json | 4 +- packages/lsp/tool-lsp/src/index.ts | 48 +++++---- packages/lsp/tool-lsp/src/render.ts | 40 +++++--- .../lsp/tool-lsp/tests/integration.spec.ts | 4 +- packages/lsp/tool-lsp/tests/render.spec.ts | 37 ++++--- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 29 ++++-- packages/lsp/tool-lsp/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 46 files changed, 680 insertions(+), 366 deletions(-) create mode 100644 packages/lsp/lsp-local/src/abort.ts diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 097eb8134e..82433d5fff 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 6a71858bb6c8ca0ad422042a22ee9085387db46d -2026-07-15-lsp-capability-seam.zh.md: 19a00a762d4f096f02386cf4e4c09e62daee5d6d +2026-07-15-lsp-capability-seam.md: 91b15e8f9ff044d2c438c87040f9fc19a8dabc6a +2026-07-15-lsp-capability-seam.zh.md: 39e63370241e8bbeb93ea7bb81fbd951fe807b19 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index 6a71858bb6..91b15e8f9f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -22,7 +22,7 @@ Add LSP as a three-package capability seam with one read-only model tool and one `dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. -The model and seam expose exactly `definition`, `references`, `implementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. +The model and seam expose exactly `goToDefinition`, `findReferences`, `goToImplementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. These operation literals match Claude Code's familiar camelCase names while the tool name and `file_path` field remain harness-owned. The prompt positions LSP as a precision aid: `Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` @@ -30,14 +30,14 @@ The prompt positions LSP as a precision aid: `Use search/read for ordinary navig `dsh-lsp` registers providers by branded id and extension-to-language-id mapping. `registerProvider()` atomically reserves the id and every normalized extension: invalid input or any conflict publishes nothing, and its disposer releases all reservations. Provider plugins register through `ctx.effect()`. Selection is per query and order-independent; no match returns a structured unavailable error. The first version has no glob, language-id, or explicit route selector and no statically declared operation capabilities. -The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` validates, selects, and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. +The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` selects and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` validates model arguments and passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. The intended contract shape is: ```ts import type { Branded } from '@deepseek-ai/dsh-brand' -type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' type LspProviderId = Branded<'LspProviderId'> interface LspPosition { @@ -77,7 +77,7 @@ interface LspService { } ``` -Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. +Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `findReferences` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. `dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. @@ -87,18 +87,18 @@ The single `lsp` tool accepts: ```ts interface LspToolInput { - readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' readonly file_path: string readonly line: number readonly character: number } ``` -`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` 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 remain outside model input. +`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable remain outside model input. The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace. -Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100`, and `maxHoverChars` defaults to `16_000` after hover normalization; both report omissions. Empty locations and `null` hover are successful no-result responses; malformed payloads remain structured errors. +Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100` and reports omitted items; `maxResultChars` defaults to `16_000` and bounds every complete rendered result, including its truncation metadata. Empty locations and `null` hover are successful no-result responses; missing or malformed server payloads fail with structured `LSP_MALFORMED_RESPONSE` errors. ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure. @@ -108,11 +108,11 @@ ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_pat The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. -Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) before hard kill; the same bounds govern failed-instance cleanup. It uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. +Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. ## Workspace, filesystem, and document synchronization -`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one handle through validation and reading. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy. +`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one `O_NOFOLLOW | O_NONBLOCK` handle through validation and reading, so a FIFO with no writer cannot block before the regular-file check. It observes caller cancellation around each filesystem operation. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy. The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers. @@ -123,7 +123,7 @@ The local provider uses a compatibility-first transient-open sequence for every 3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. 4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. -Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-instance queue serializes complete lifecycles; distinct instances may run in parallel. The server's workspace index remains responsible for closed files reached from the source. +Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-workspace provider queue serializes source-read/open/query/close lifecycles, so a waiting query reads current bytes only when its turn starts; the instance also keeps protocol lifecycles serialized. Distinct workspaces may run in parallel. The server's workspace index remains responsible for closed files reached from the source. The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider. @@ -133,7 +133,7 @@ The canonical workspace `realpath` must be a directory and supplies process cwd, Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits. -Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Hover normalization takes `MarkupContent.value`, preserves string `MarkedString` values, renders language-tagged values as fenced code, joins arrays with one blank line, and applies `maxHoverChars` last. +Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Positions must be nonnegative integers. Hover normalization accepts only valid `MarkupContent` and `MarkedString` shapes, preserves string values, renders language-tagged values as fenced code, and joins arrays with one blank line. The model-facing tool applies `maxResultChars` after rendering. Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence. @@ -176,10 +176,10 @@ The local provider trusts its configured server and claims no sandbox confinemen - Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication. - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. - Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. -- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `references.includeDeclaration`. +- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`. - Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection. - Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. -- Lifecycle tests pin startup single-flight, per-instance serialization, cross-instance parallelism, abortable queues, crash replacement without replay, and quiescent disposal. +- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal. - Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. - A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping. - Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup. diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 19a00a762d..39e6337024 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -22,22 +22,22 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 `dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 -模型与服务边界仅公开 `definition`、`references`、`implementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。 +模型与服务边界仅公开 `goToDefinition`、`findReferences`、`goToImplementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。这些操作字面量与 Claude Code 熟悉的 camelCase 命名一致,而工具名与 `file_path` 字段仍由 harness 自行定义。 提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` -## Package 与职责边界 +## 包与职责边界 `dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。 -服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行校验、选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 +服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 校验模型参数,并只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 预期契约如下: ```ts import type { Branded } from '@deepseek-ai/dsh-brand' -type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' type LspProviderId = Branded<'LspProviderId'> interface LspPosition { @@ -77,7 +77,7 @@ interface LspService { } ``` -映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 +映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 `dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 @@ -87,18 +87,18 @@ interface LspService { ```ts interface LspToolInput { - readonly operation: 'definition' | 'references' | 'implementation' | 'hover' + readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' readonly file_path: string readonly line: number readonly character: number } ``` -`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`references` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。 +`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`findReferences` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。 工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。 -位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,`maxHoverChars` 在 `hover` 归一化后应用,默认值为 `16_000`;两者都会报告省略数量。空位置与 `null` hover 是成功的无结果响应;格式错误的载荷保持为结构化错误。 +位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。 ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。 @@ -108,11 +108,11 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 -提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`)限制强制终止前的宽限期;失败实例的清理也使用相同边界。它使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 +提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 ## 工作区、文件系统与文档同步 -`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一句柄。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 +`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一个 `O_NOFOLLOW | O_NONBLOCK` 句柄,因此没有写入方的 FIFO 不会在普通文件校验前造成阻塞。它在每项文件系统操作前后检查调用方是否取消。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 `read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 @@ -123,7 +123,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 -每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个实例使用一个可取消队列串行执行完整生命周期;不同实例可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。 +每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个工作区的提供方队列可取消,并串行执行源文件读取、打开、查询和关闭的完整生命周期,因此等待中的查询只在轮到它时才读取当前字节;实例也会串行执行协议生命周期。不同工作区可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。 规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。 @@ -133,7 +133,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 -导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。`hover` 归一化直接采用 `MarkupContent.value`,保留字符串 `MarkedString`,把带语言标签的值渲染为围栏代码块,以一个空行连接数组,并在最后应用 `maxHoverChars`。 +导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。位置必须是非负整数。`hover` 归一化只接受有效的 `MarkupContent` 和 `MarkedString` 结构,保留字符串值,把带语言标签的值渲染为围栏代码块,并以一个空行连接数组。面向模型的工具在渲染后应用 `maxResultChars`。 取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。 @@ -176,10 +176,10 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 包测试固定三个包的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 - 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 -- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `references.includeDeclaration`。 +- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。 - 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 - 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 -- 生命周期测试固定启动 single-flight、实例内串行、跨实例并行、可取消队列、崩溃后不重放的替换,以及释放后完全停稳。 +- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。 - 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 - 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。 - 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 diff --git a/AGENTS.md b/AGENTS.md index 6a0e4ca13b..5835149048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools + lsp/ language-server seam + local stdio provider + model-facing lsp tool skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8c7abf7d14..1d7930a84e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -650,12 +650,12 @@ export interface LspLocalServerConfig { maxDocumentBytes?: number /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:83`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` @@ -1188,14 +1188,14 @@ Requires: `tools` · `lsp` · `systemPrompt` 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 + /** Largest complete rendered result in characters, including truncation metadata (default 16000). */ + maxResultChars?: number /** Tool-call timeout budget in ms (default 60000). */ timeoutMs?: number } ``` -Source: [`packages/lsp/tool-lsp/src/index.ts:56`](../packages/lsp/tool-lsp/src/index.ts) +Source: [`packages/lsp/tool-lsp/src/index.ts:58`](../packages/lsp/tool-lsp/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md index 6bf63c20c2..eb370f6e38 100644 --- a/docs/core-data-structures/lsp.md +++ b/docs/core-data-structures/lsp.md @@ -14,7 +14,7 @@ The seam and model expose exactly four semantic queries; the union is closed, so * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are * deliberately deferred (they need different schemas). */ -type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' ``` ```ts type-equiv @@ -71,7 +71,7 @@ interface LspProviderQuery extends LspQueryRequest { ## Result -A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root. +A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `findReferences` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root. ```ts type-equiv /** One resolved location: a document URI and the range within it. */ @@ -95,9 +95,9 @@ interface LspHover { ```ts type-equiv /** - * The closed result union. Navigation operations (`definition`, `references`, `implementation`) - * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` - * to exhaustiveness so a new arm breaks compilation until handled. + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. * * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that @@ -116,8 +116,9 @@ A provider owns a stable branded `id` and an exclusive lowercase leading-dot ext ```ts type-equiv /** * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link - * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references` - * always includes declarations — the provider enforces this internally; callers get no flag. + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. */ interface LspProvider { /** Stable provider identity, reserved atomically with the extension mappings. */ @@ -159,4 +160,4 @@ interface LspService { } ``` -`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with a stable `code` (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`) callers route on instead of parsing `message`. +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with stable codes such as `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and `LSP_MALFORMED_RESPONSE`, which callers route on instead of parsing `message`. diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bbfe9c93d..715ac67732 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -404,6 +404,7 @@ flowchart TD pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp pkg_tool_lsp --> pkg_system_prompt + pkg_tool_lsp --> pkg_timeout pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_tools @@ -610,7 +611,7 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | -| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 326f6c02a7..8273203616 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -492,7 +492,7 @@ create, edit, pause, and resume require direct-human root authority; complete an ### `lsp` -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. +Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration. ```json { @@ -500,11 +500,11 @@ Query a language server for precise code navigation. operation is one of definit "properties": { "operation": { "type": "string", - "description": "definition, references, implementation, or hover.", + "description": "goToDefinition, findReferences, goToImplementation, or hover.", "enum": [ - "definition", - "references", - "implementation", + "goToDefinition", + "findReferences", + "goToImplementation", "hover" ] }, diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml index 4d24ead86d..dc672376b5 100644 --- a/examples/acp-agent/tests/lsp.cordis.snapshot.yml +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -19,6 +19,8 @@ args: ['./lsp-server.mjs'] extensionToLanguage: '.ts': typescript + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' - id: tool-lsp name: '@deepseek-ai/dsh-tool-lsp' config: diff --git a/examples/acp-agent/tests/lsp.cordis.yml b/examples/acp-agent/tests/lsp.cordis.yml index f1eefa99af..49c9099d65 100644 --- a/examples/acp-agent/tests/lsp.cordis.yml +++ b/examples/acp-agent/tests/lsp.cordis.yml @@ -17,6 +17,8 @@ args: ['./lsp-server.mjs'] extensionToLanguage: '.ts': typescript + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' - id: tool-lsp name: '@deepseek-ai/dsh-tool-lsp' config: diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl index 36fb9ad8f2..00780e7787 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -4,12 +4,12 @@ {"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} {"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} {"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} -{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"definition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} {"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl index 9e527ea2c5..ce84702157 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl @@ -1,7 +1,7 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP definition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP goToDefinition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_lsp_definition","status":"completed","content":[{"type":"content","content":{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index ad82d538b7..8e49c2dce5 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -15,7 +15,7 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -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. +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. findReferences always includes the declaration. Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 1105e030c6..4fa5010b72 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -117,17 +117,17 @@ }, { "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.", + "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", "parameters": { "type": "object", "properties": { "operation": { "type": "string", - "description": "definition, references, implementation, or hover.", + "description": "goToDefinition, findReferences, goToImplementation, or hover.", "enum": [ - "definition", - "references", - "implementation", + "goToDefinition", + "findReferences", + "goToImplementation", "hover" ] }, diff --git a/knip.json b/knip.json index 8565e5b462..e80eb596e5 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], - "ignore": ["examples/*/tests/snapshots/*/workspace/**/*"], "ignoreBinaries": ["bwrap", "python3", "sandbox-exec"], "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { @@ -14,6 +13,7 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 12e8722671..147888a259 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -8,6 +8,6 @@ The language-server capability seam: an abstract LSP interface, a generic stdio | `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) | | `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | -The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. +The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 35f109620a..66568b17d1 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -9,7 +9,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. -- Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel. +- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration @@ -28,13 +28,13 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v | `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | | `maxDocumentBytes` | `4000000` | Largest source file this host will open. | | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | -| `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. | +| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. | -`servers` must contain at least one entry, and every id must be non-empty. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. +`servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. ## Protocol behavior -Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes valid `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. Missing results, malformed ranges or positions, and malformed hover encodings fail as structured `LSP_MALFORMED_RESPONSE` errors. ## Security boundary @@ -50,6 +50,6 @@ No direct invalidation; `dsh-tool-lsp` owns request-prefix changes. ## Known Limitations and Deferred Work -- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. +- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. - **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. - **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index d437e91109..68e6cf7764 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-lsp-local", - "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open definition/references/implementation/hover queries in the host filesystem namespace", + "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/lsp/lsp-local/src/abort.ts b/packages/lsp/lsp-local/src/abort.ts new file mode 100644 index 0000000000..7790069e44 --- /dev/null +++ b/packages/lsp/lsp-local/src/abort.ts @@ -0,0 +1,48 @@ +/** + * Shared cancellation helpers for the local LSP provider's host-I/O, queue, and protocol phases. + * @module @deepseek-ai/dsh-lsp-local/abort + */ + +import { timeoutOf } from '@deepseek-ai/dsh-timeout' + +/** + * Build an abort Error carrying the signal's reason and preserving timeout classification. + * @param signal - the aborted signal whose reason to surface. + * @returns the timeout reason if present, else the Error reason, else a generic aborted Error. + */ +export function abortError(signal: AbortSignal): Error { + const timeout = timeoutOf(signal) + if (timeout !== undefined) return timeout + const reason: unknown = signal.reason + if (reason instanceof Error) return reason + return new Error('LSP query aborted') +} + +/** + * Throw the signal's classified abort error when it has already fired. + * @param signal - the optional query cancellation signal. + */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw abortError(signal) +} + +/** + * Await work while allowing a query signal to abandon its wait; the underlying work keeps its own + * handlers and continues to its owner-defined quiescence boundary. + * @param work - the owned asynchronous work. + * @param signal - optional query cancellation. + * @returns the work result, or a rejection carrying the classified abort reason. + */ +export function abortable(work: Promise, signal?: AbortSignal): Promise { + if (signal === undefined) return work + if (signal.aborted) return Promise.reject(abortError(signal)) + const canceled = Promise.withResolvers() + const onAbort = (): void => { canceled.reject(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + const normalized = work.catch((error: unknown) => { + /* v8 ignore next -- owned LSP promises reject with Error; coercion defends the generic helper. */ + throw error instanceof Error ? error : new Error(String(error)) + }) + return Promise.race([normalized, canceled.promise]) + .finally(() => { signal.removeEventListener('abort', onAbort) }) +} diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 6acb321e90..ae725b56f7 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -75,10 +75,10 @@ export class LspConnection { }) }) this.child.on('error', (error) => { this.fail(error) }) - // A write to the child's stdin after it exits emits an async 'error'; swallow it so an EPIPE - // during teardown does not crash the process. Pending requests fail via the 'close' handler. - /* v8 ignore next -- the handler only fires on an async stdin write error during teardown. */ - this.child.stdin.on('error', () => { /* swallow */ }) + // Child stdin can fail while the process itself remains alive (for example, a server closes fd + // 0). Treat that as a fatal connection error so pending requests reject immediately instead of + // waiting for a process-close event that may never arrive. + this.child.stdin.on('error', (error) => { this.fail(error) }) this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) } @@ -108,15 +108,9 @@ export class LspConnection { return } this.pending.set(id, { resolve, reject }) - try { - this.write({ jsonrpc: '2.0', id, method, params }) - } catch (error) { - /* v8 ignore start -- a stdin write failure surfaces asynchronously via the swallowed - 'error' listener, so this synchronous catch is a defensive guard. */ - this.pending.delete(id) - reject(asError(error)) - /* v8 ignore stop */ - } + // `write()` records either synchronous or callback-delivered failures on the connection and + // rejects every pending request. This handler only consumes the write promise itself. + void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {}) }) // A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later // when the process closes; a benign no-op handler keeps that from surfacing as an unhandled @@ -129,9 +123,10 @@ export class LspConnection { * Send a notification (no id, no response). * @param method - the JSON-RPC method. * @param params - the notification params. + * @returns a promise that settles when the framed notification has been written. */ - notify(method: string, params: unknown): void { - this.write({ jsonrpc: '2.0', method, params }) + notify(method: string, params: unknown): Promise { + return this.write({ jsonrpc: '2.0', method, params }) } /** @@ -139,11 +134,9 @@ export class LspConnection { * @param requestId - the numeric id of the request to cancel. */ cancel(requestId: number): void { - try { - this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }) - } catch { - // The server is already gone or unwritable; the pending request will fail on close. - } + // The server is already gone or unwritable when this rejects; `write()` has recorded the fatal + // connection failure and rejected the pending request, so cancellation remains best-effort. + void this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }).catch(() => {}) } /** @@ -253,7 +246,10 @@ export class LspConnection { const id = frame.id const method = frame.method if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) { - void this.handleServerRequest(id, method, frame.params) + // A response-write failure has already invalidated the connection in `write()`. + /* v8 ignore next -- protocol tests exercise response writes; only a simultaneous connection + failure makes this consumption handler run. */ + void this.handleServerRequest(id, method, frame.params).catch(() => {}) return } if (typeof method === 'string') { @@ -266,9 +262,9 @@ export class LspConnection { private async handleServerRequest(id: number | string, method: string, params: unknown): Promise { try { const result = await this.onServerRequest(method, params) - this.write({ jsonrpc: '2.0', id, result }) + await this.write({ jsonrpc: '2.0', id, result }) } catch (error) { - this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } }) + await this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } }) } } @@ -285,8 +281,28 @@ export class LspConnection { pending.resolve(frame.result) } - private write(message: unknown): void { - this.child.stdin.write(encodeMessage(message)) + private write(message: unknown): Promise { + if (this.closeReason !== undefined) return Promise.reject(this.closeReason) + return new Promise((resolve, reject) => { + const done = (error?: Error | null): void => { + if (error === undefined || error === null) { + resolve() + return + } + this.fail(error) + reject(error) + } + try { + this.child.stdin.write(encodeMessage(message), done) + /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a + nonconforming Writable implementation throwing synchronously. */ + } catch (error) { + const failure = asError(error) + this.fail(failure) + reject(failure) + } + /* v8 ignore stop */ + }) } /** The exit-close error message, appending the retained stderr tail when the server wrote any. */ diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts index 949a1b838b..11996908ac 100644 --- a/packages/lsp/lsp-local/src/host.ts +++ b/packages/lsp/lsp-local/src/host.ts @@ -13,6 +13,7 @@ import { constants } from 'node:fs' import { open, realpath, stat } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { isAbsolute, resolve as resolvePath, sep } from 'node:path' +import { throwIfAborted } from './abort.ts' /** A validated source: its canonical absolute path and current UTF-8 text. */ export interface HostSource { @@ -27,17 +28,21 @@ export interface HostSource { * process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots * collapse to one instance. * @param workspaceRoot - the caller's workspace root (absolute). + * @param signal - optional cancellation observed around each filesystem operation. * @returns the canonical directory path. * @throws Error when the path is missing or not a directory. */ -export async function canonicalizeWorkspace(workspaceRoot: string): Promise { +export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise { + throwIfAborted(signal) let canonical: string try { canonical = await realpath(workspaceRoot) } catch (error) { throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`) } + throwIfAborted(signal) const info = await stat(canonical) + throwIfAborted(signal) if (!info.isDirectory()) { throw new Error(`workspace root "${workspaceRoot}" is not a directory`) } @@ -52,6 +57,7 @@ export async function canonicalizeWorkspace(workspaceRoot: string): Promise { + throwIfAborted(signal) const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath) let canonicalPath: string try { @@ -67,6 +75,7 @@ export async function readHostSource( } catch (error) { throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`) } + throwIfAborted(signal) if (!isInside(canonicalWorkspace, canonicalPath)) { throw new Error(`source "${filePath}" resolves outside the workspace`) } @@ -74,9 +83,12 @@ export async function readHostSource( // realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we // actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a // symlink between realpath and open (which would otherwise escape the workspace). - const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW) + // O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular. + const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) try { + throwIfAborted(signal) const info = await handle.stat() + throwIfAborted(signal) if (!info.isFile()) { throw new Error(`source "${filePath}" is not a regular file`) } @@ -85,8 +97,9 @@ export async function readHostSource( } // Bound the read to the cap even if the file grew after stat: read one extra byte and reject on // overflow, so a concurrent grow cannot defeat the memory bound. - const buffer = await readCapped(handle, maxDocumentBytes, filePath) + const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal) const text = decodeUtf8Strict(buffer, filePath) + throwIfAborted(signal) return { canonicalPath, text } } finally { await handle.close() @@ -94,12 +107,19 @@ export async function readHostSource( } /** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */ -async function readCapped(handle: FileHandle, maxBytes: number, filePath: string): Promise { +async function readCapped( + handle: FileHandle, + maxBytes: number, + filePath: string, + signal?: AbortSignal, +): Promise { const limit = maxBytes + 1 const chunk = Buffer.allocUnsafe(limit) let total = 0 for (;;) { + throwIfAborted(signal) const { bytesRead } = await handle.read(chunk, total, limit - total, total) + throwIfAborted(signal) if (bytesRead === 0) break total += bytesRead /* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */ diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 67e86da11c..095d761624 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -15,14 +15,16 @@ import { accessSync, constants, statSync } from 'node:fs' import { delimiter, isAbsolute, join } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' -import { LspProviderId } from '@deepseek-ai/dsh-lsp' +import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' import type { LspProvider, LspProviderQuery, LspQueryResult, } from '@deepseek-ai/dsh-lsp' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { abortable, abortError } from './abort.ts' import { canonicalizeWorkspace, readHostSource } from './host.ts' -import { abortError, LspInstance } from './instance.ts' +import { LspInstance } from './instance.ts' import type { InstanceSpec } from './instance.ts' export { canonicalizeWorkspace, readHostSource } from './host.ts' @@ -75,7 +77,7 @@ export interface LspLocalServerConfig { maxDocumentBytes?: number /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ shutdownTimeoutMs?: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */ + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ killGraceMs?: number } @@ -98,8 +100,8 @@ const LspLocalServerConfig: z = z.object({ maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES), maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES), maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES), - shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS), - killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS), + shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS), + killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS), }) export const Config: z = z.object({ @@ -148,8 +150,8 @@ export function apply(ctx: Context, config: Config): void { function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void { // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. - assertPositiveInteger(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) - assertPositiveInteger(providerId, 'killGraceMs', resolved.killGraceMs) + assertTimer(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertTimer(providerId, 'killGraceMs', resolved.killGraceMs) // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad // document cap fails later in the read path instead of at load. @@ -158,6 +160,13 @@ function validateServerConfig(providerId: string, resolved: ResolvedServerConfig assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes) } +/** Reject a timer value Node would clamp instead of scheduling as configured. */ +function assertTimer(providerId: string, name: string, value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + /** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */ function assertPositiveInteger(providerId: string, name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { @@ -171,6 +180,8 @@ class LocalLspProvider implements LspProvider { readonly extensionToLanguage: Readonly> /** One live instance per canonical workspace realpath. */ private readonly instances = new Map() + /** One complete source-read→open→query→close serialization tail per canonical workspace. */ + private readonly queues = new Map>() private disposed = false constructor( @@ -192,35 +203,49 @@ class LocalLspProvider implements LspProvider { private assertActive(signal?: AbortSignal): void { /* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls exercise the post-await check instead. */ - if (this.isDisposed()) throw new Error('lsp-local provider is disposed') + if (this.isDisposed()) throw new LspError('lsp-local provider is disposed', 'LSP_DISPOSED') if (signal?.aborted) throw abortError(signal) } async query(request: LspProviderQuery, signal?: AbortSignal): Promise { // Honor an already-aborted signal before host I/O so a canceled request never starts a server. this.assertActive(signal) - const workspace = await canonicalizeWorkspace(request.workspaceRoot) - // Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized - // source must fail without leaving an idle process pooled (the pre-start rejection contract), and - // the single-handle read preserves the containment/size checks against a mid-read swap. - const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes) - // Re-check disposal after the awaits: disposeAll() may have snapshotted the instance map while we - // were canonicalizing/reading, so creating a server now would leave it unowned by teardown. + const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal) this.assertActive(signal) - let instance = this.instanceFor(workspace) - // Eviction and replacement are synchronous so disposal cannot snapshot the pool between them and - // miss a newly spawned process. - if (instance.dead) { - this.evictIfCurrent(workspace, instance) - instance = this.instanceFor(workspace) - } - try { - return await instance.query(request, source, signal) - } finally { - // A crashed/closed process must not be reused: drop its slot so the next query starts fresh, - // but only if the slot still holds THIS instance (a concurrent replacement must survive). - if (instance.dead) this.evictIfCurrent(workspace, instance) - } + return this.enqueue(workspace, signal, async () => { + this.assertActive(signal) + // Read inside the workspace queue but before spawning: a queued query sees current bytes when + // its turn starts, while an invalid source still cannot leave an idle process pooled. + const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal) + // Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a + // synchronous get-or-create so every spawned process remains owned by teardown. + this.assertActive(signal) + let instance = this.instanceFor(workspace) + if (instance.dead) { + this.evictIfCurrent(workspace, instance) + instance = this.instanceFor(workspace) + } + try { + return await instance.query(request, source, signal) + } finally { + // Drop a crashed slot only when it still owns this instance; a replacement must survive. + if (instance.dead) this.evictIfCurrent(workspace, instance) + } + }) + } + + /** Serialize one complete query lifecycle for a canonical workspace. */ + private enqueue(workspace: string, signal: AbortSignal | undefined, run: () => Promise): Promise { + const previous = this.queues.get(workspace) ?? Promise.resolve() + const result = abortable(previous, signal).then(run) + // The tail follows the actual prior work even when this caller aborts its wait. It never rejects, + // so later callers serialize without inheriting an earlier query's outcome. + const tail = previous.then(() => result).then(() => undefined, () => undefined) + this.queues.set(workspace, tail) + void tail.then(() => { + if (this.queues.get(workspace) === tail) this.queues.delete(workspace) + }) + return result } /** Return or synchronously publish the one instance for a canonical workspace. */ @@ -259,8 +284,13 @@ class LocalLspProvider implements LspProvider { async disposeAll(): Promise { this.disposed = true const live = [...this.instances.values()] + const draining = [...this.queues.values()] this.instances.clear() - await Promise.all(live.map(instance => instance.dispose())) + await Promise.all([ + ...live.map(instance => instance.dispose()), + ...draining, + ]) + this.queues.clear() } } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index fb67f6e777..9d327fb795 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -14,7 +14,8 @@ import type { LspProviderQuery, LspQueryResult, } from '@deepseek-ai/dsh-lsp' -import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { deadline } from '@deepseek-ai/dsh-timeout' +import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' import type { ConnectionSpec } from './connection.ts' import type { HostSource } from './host.ts' @@ -83,7 +84,7 @@ export class LspInstance { // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up // rather than block on the shared tail forever. - const run = this.abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) + const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up // on the wait does not deserialize the queue. @@ -103,11 +104,11 @@ export class LspInstance { // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. negotiatePositionEncoding(capabilities.positionEncoding) this.capabilities = capabilities - this.connection.notify('initialized', {}) + await this.connection.notify('initialized', {}) } private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { - if (this.disposed) throw new Error('LSP instance was disposed') + if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED') /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ if (signal?.aborted) throw abortError(signal) // Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends @@ -115,11 +116,10 @@ export class LspInstance { // negotiation, malformed result) without the process exiting — tear the instance down so a // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. try { - await this.abortable(this.ready, signal) + await abortable(this.ready, signal) } catch (error) { if (!this.dead) { - /* v8 ignore next -- ready rejects with an Error (abort reason or initialize failure); the String() fallback is defensive. */ - await this.startTeardown(error instanceof Error ? error : new Error(String(error))) + await this.startTeardown() } throw error } @@ -138,7 +138,7 @@ export class LspInstance { try { /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) - this.connection.notify('textDocument/didOpen', { + await this.connection.notify('textDocument/didOpen', { textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, }) opened = true @@ -150,34 +150,21 @@ export class LspInstance { // the next queued query's document lifecycle overlap the still-active request. if (opened && !this.dead) { try { - this.connection.notify('textDocument/didClose', { textDocument: { uri } }) - } catch (error) { - /* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error' - listener, so a synchronous didClose write failure is a defensive path. */ + await this.connection.notify('textDocument/didClose', { textDocument: { uri } }) + } catch { // A close-write failure does not replace the settled result/error, but the instance can no // longer be trusted: invalidate it and await bounded process termination. - void this.startTeardown(error instanceof Error ? error : new Error(String(error))) - /* v8 ignore stop */ + try { + await this.startTeardown() + } catch { + /* v8 ignore next -- teardown owns all expected process races; this only preserves the + already-settled query outcome if an unexpected cleanup primitive itself rejects. */ + } } } } } - /** - * Await `work`, but reject as soon as `signal` aborts. The underlying `work` promise keeps its own - * handlers, so an orphaned rejection after abort is not unhandled. - */ - private abortable(work: Promise, signal: AbortSignal | undefined): Promise { - if (signal === undefined) return work - /* v8 ignore next -- callers either pre-check the signal or pass a freshly armed teardown deadline. */ - if (signal.aborted) return Promise.reject(abortError(signal)) - return new Promise((resolve, reject) => { - const onAbort = (): void => { reject(abortError(signal)) } - signal.addEventListener('abort', onAbort, { once: true }) - work.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort) }) - }) - } - private async sendRequest( operation: LspOperation, uri: string, @@ -187,9 +174,9 @@ export class LspInstance { const params = { textDocument: { uri }, position: { line: position.line, character: position.character }, - // references always includes declarations: the caller gets no flag and impact analysis never - // omits the defining site. - ...(operation === 'references' ? { context: { includeDeclaration: true } } : {}), + // findReferences always includes declarations: the caller gets no flag and impact analysis + // never omits the defining site. + ...(operation === 'findReferences' ? { context: { includeDeclaration: true } } : {}), } const requestId = this.connection.peekNextId() const send = this.connection.request(requestMethod(operation), params) @@ -204,7 +191,7 @@ export class LspInstance { */ private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { try { - return await this.abortable(send, signal) + return await abortable(send, signal) } catch (error) { if (!signal.aborted) throw error this.connection.cancel(requestId) @@ -221,7 +208,7 @@ export class LspInstance { grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) }), ]) - if (!settled) await this.startTeardown(abortError(signal)) + if (!settled) await this.startTeardown() } finally { grace[Symbol.dispose]() } @@ -263,17 +250,17 @@ export class LspInstance { * process close so nothing outlives disposal. */ async dispose(): Promise { - await this.startTeardown(new Error('LSP instance disposed')) + await this.startTeardown() } /** Publish disposal once and make every caller await the same quiescence boundary. */ - private startTeardown(reason: Error): Promise { + private startTeardown(): Promise { this.disposed = true - this.teardownPromise ??= this.tearDown(reason) + this.teardownPromise ??= this.tearDown() return this.teardownPromise } - private async tearDown(_reason: Error): Promise { + private async tearDown(): Promise { const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') try { await this.gracefulShutdown(shutdownDeadline.signal) @@ -287,9 +274,9 @@ export class LspInstance { /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */ private async gracefulShutdown(signal: AbortSignal): Promise { - await this.abortable(this.connection.request('shutdown', null), signal) - this.connection.notify('exit', null) - await this.abortable(this.connection.closed, signal) + await abortable(this.connection.request('shutdown', null), signal) + await this.connection.notify('exit', null) + await abortable(this.connection.closed, signal) } /** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */ @@ -322,19 +309,6 @@ function markSettled(): boolean { return true } -/** - * Build an abort Error carrying the signal's reason (preserving a timeout classification). - * @param signal - the aborted signal whose reason to surface. - * @returns the timeout reason if the signal carries one, else the signal's Error reason, else a generic aborted Error. - */ -export function abortError(signal: AbortSignal): Error { - const timeout = timeoutOf(signal) - if (timeout !== undefined) return timeout - const reason: unknown = signal.reason - if (reason instanceof Error) return reason - return new Error('LSP query aborted') -} - /** * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and * configuration, markdown/plaintext hover, and link support for definition/implementation. No diff --git a/packages/lsp/lsp-local/src/translate.ts b/packages/lsp/lsp-local/src/translate.ts index a208c3bedf..a49246c8bb 100644 --- a/packages/lsp/lsp-local/src/translate.ts +++ b/packages/lsp/lsp-local/src/translate.ts @@ -11,6 +11,7 @@ import type { LspOperation, LspRange, } from '@deepseek-ai/dsh-lsp' +import { LspError } from '@deepseek-ai/dsh-lsp' import { assertNever } from '@deepseek-ai/dsh-llm' import type { WireHover, @@ -30,9 +31,9 @@ import type { */ export function requestMethod(operation: LspOperation): string { switch (operation) { - case 'definition': return 'textDocument/definition' - case 'references': return 'textDocument/references' - case 'implementation': return 'textDocument/implementation' + case 'goToDefinition': return 'textDocument/definition' + case 'findReferences': return 'textDocument/references' + case 'goToImplementation': return 'textDocument/implementation' case 'hover': return 'textDocument/hover' /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ default: return assertNever(operation, 'requestMethod') @@ -42,9 +43,9 @@ export function requestMethod(operation: LspOperation): string { /** The `ServerCapabilities` provider field backing each operation. */ function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability { switch (operation) { - case 'definition': return capabilities.definitionProvider - case 'references': return capabilities.referencesProvider - case 'implementation': return capabilities.implementationProvider + case 'goToDefinition': return capabilities.definitionProvider + case 'findReferences': return capabilities.referencesProvider + case 'goToImplementation': return capabilities.implementationProvider case 'hover': return capabilities.hoverProvider /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ default: return assertNever(operation, 'capabilityValue') @@ -127,7 +128,12 @@ function isRange(value: unknown): value is WireRange { function isPosition(value: unknown): boolean { if (value === null || typeof value !== 'object') return false const position = value as Record - return typeof position.line === 'number' && typeof position.character === 'number' + return isProtocolCoordinate(position.line) && isProtocolCoordinate(position.character) +} + +/** Whether a wire coordinate is a valid nonnegative integer. */ +function isProtocolCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 } /** @@ -138,12 +144,13 @@ function isPosition(value: unknown): boolean { * @throws Error when an element is neither a `Location` nor a `LocationLink`. */ export function normalizeLocations(payload: unknown): LspLocation[] { - if (payload === null || payload === undefined) return [] + if (payload === null) return [] + if (payload === undefined) throw malformedResponse('LSP navigation result was missing') const elements = Array.isArray(payload) ? payload : [payload] const locations: LspLocation[] = [] for (const element of elements) { if (element === null || typeof element !== 'object') { - throw new Error('LSP navigation result contained a non-object entry') + throw malformedResponse('LSP navigation result contained a non-object entry') } const record = element as Record if (isLocationLink(record)) { @@ -153,7 +160,7 @@ export function normalizeLocations(payload: unknown): LspLocation[] { const location = record as unknown as WireLocation locations.push({ uri: location.uri, range: toRange(location.range) }) } else { - throw new Error('LSP navigation result contained neither a Location nor a LocationLink') + throw malformedResponse('LSP navigation result contained neither a Location nor a LocationLink') } } return locations @@ -168,39 +175,61 @@ function renderMarkedString(value: WireMarkedString): string { /** * Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string * `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array - * joins its rendered parts with one blank line. `maxHoverChars` is NOT applied here — the tool caps. + * joins its rendered parts with one blank line. The model-facing tool owns the complete result cap. * @param payload - the raw `textDocument/hover` result. * @returns the normalized hover, or `null` when there is no content. * @throws Error when the payload is a non-null, non-object, or structurally invalid hover. */ export function normalizeHover(payload: unknown): LspHover | null { - if (payload === null || payload === undefined) return null - if (typeof payload !== 'object') throw new Error('LSP hover result was not an object') + if (payload === null) return null + if (payload === undefined) throw malformedResponse('LSP hover result was missing') + if (typeof payload !== 'object') throw malformedResponse('LSP hover result was not an object') const hover = payload as unknown as WireHover const contents = renderHoverContents(hover.contents) if (contents === '') return null const range = hover.range - return range !== undefined && isRange(range) ? { contents, range: toRange(range) } : { contents } + if (range === undefined) return { contents } + if (!isRange(range)) throw malformedResponse('LSP hover result contained a malformed range') + return { contents, range: toRange(range) } } /** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */ function renderHoverContents(contents: unknown): string { if (contents === null || contents === undefined) { - throw new Error('LSP hover result had no contents') + throw malformedResponse('LSP hover result had no contents') } if (typeof contents === 'string') return contents if (Array.isArray(contents)) { - return contents.map(renderMarkedString).join('\n\n') + return contents.map((value) => { + if (isMarkedString(value)) return renderMarkedString(value) + throw malformedResponse('LSP hover contents contained a malformed MarkedString') + }).join('\n\n') } if (typeof contents !== 'object') { - throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') + throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array') } const record = contents as Record if (record.kind === 'markdown' || record.kind === 'plaintext') { - return typeof record.value === 'string' ? record.value : '' + if (typeof record.value !== 'string') { + throw malformedResponse('LSP hover MarkupContent value was not a string') + } + return record.value } if (typeof record.language === 'string' && typeof record.value === 'string') { return renderMarkedString({ language: record.language, value: record.value }) } - throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array') + throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array') +} + +/** Whether an untrusted value is either form of `MarkedString`. */ +function isMarkedString(value: unknown): value is WireMarkedString { + if (typeof value === 'string') return true + if (value === null || typeof value !== 'object') return false + const record = value as Record + return typeof record.language === 'string' && typeof record.value === 'string' +} + +/** Create the stable structured error used for malformed server result payloads. */ +function malformedResponse(message: string): LspError { + return new LspError(message, 'LSP_MALFORMED_RESPONSE') } diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index 799069c0fd..a2da86d87c 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -18,9 +18,7 @@ const pkgDir = fileURLToPath(new URL('..', import.meta.url)) const seamLib = join(pkgDir, '../lsp/lib/index.js') const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) -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 @@ -49,13 +47,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { servers: { fake: { command: ${JSON.stringify(process.execPath)}, - args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], - env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, + args: [${JSON.stringify(fixtureServer)}], + env: { LSP_FAKE_DEF: ${JSON.stringify(location)} }, extensionToLanguage: { '.ts': 'typescript' }, }, }, }) - const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) + const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) console.log(JSON.stringify(result)) await ctx.fiber.dispose() ` diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 25ac9f2971..6d9ca6d6a3 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -2,9 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' -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)) /** A recorded server→client request the test's handler saw. */ interface SeenRequest { method: string; params: unknown } @@ -27,9 +25,9 @@ function connect( ): LspConnection { const conn = new LspConnection({ command: process.execPath, - args: ['--import', tsxLoader, fixtureServer], + args: [fixtureServer], cwd: process.cwd(), - env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, + env: { ...process.env as Record, ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, configuration: { setting: 42 }, @@ -69,7 +67,7 @@ describe('LspConnection', () => { seen, ) await conn.request('initialize', { capabilities: {} }) - conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) await waitFor(() => seen.some(s => s.method === 'workspace/configuration')) expect(seen[0]?.method).toBe('workspace/configuration') }) @@ -77,7 +75,7 @@ describe('LspConnection', () => { it('drops a server→client notification without replying', async () => { const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' }) await conn.request('initialize', { capabilities: {} }) - conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) // No throw and the connection stays usable. await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() }) @@ -90,7 +88,7 @@ describe('LspConnection', () => { seen, ) await conn.request('initialize', { capabilities: {} }) - conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit')) // The connection remains healthy after emitting the error response. await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() @@ -211,6 +209,15 @@ describe('LspConnection edge behavior', () => { await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) }) + it('rejects a pending request when child stdin closes but the process stays alive', async () => { + const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)') + await new Promise(resolve => setTimeout(resolve, 100)) + const timeout = new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('request timed out')) }, 1000) + }) + await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/) + }) + it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { // A frame with a string id and no method: not dispatchable; the client must ignore it and still // answer our real request. diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 1654cb820d..8dec856274 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -12,6 +12,9 @@ * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request, * simulating a server that dies while idle so the pool holds a dead instance (eviction test). + * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds. + * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. + * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of @@ -19,10 +22,10 @@ * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response. * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply. * - * Run: node --import tsx fixture-server.ts + * Run: node fixture-server.ts (Node's erasable TypeScript syntax support). */ -import { appendFileSync } from 'node:fs' +import { appendFileSync, closeSync } from 'node:fs' const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 @@ -30,6 +33,9 @@ const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse( const hang = process.env.LSP_FAKE_HANG === '1' const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' +const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) +const openMarker = process.env.LSP_FAKE_OPEN_MARKER +const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' @@ -124,17 +130,29 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul } if (method === 'textDocument/didOpen') { if (crashOnOpen) process.exit(1) + if (openMarker !== undefined) { + const params = message.params as { textDocument?: { text?: unknown } } | undefined + appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`) + } if (onOpen !== undefined) emitServerRequest(onOpen) return } if (method === 'textDocument/didClose' || method === 'initialized') return if (method?.startsWith('textDocument/')) { if (hang) return - if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return } - send({ id, result: resultFor(method) }) - // Simulate an idle death: answer this request, then exit before the next one arrives so the pool - // is left holding a dead instance. - if (exitAfterReply) setTimeout(() => process.exit(0), 20) + const reply = (): void => { + if (closeStdinAfterReply) closeSync(0) + if (errorReply) { + send({ id, error: { code: -32000, message: 'server refused the request' } }) + } else { + send({ id, result: resultFor(method) }) + } + // Simulate an idle death: answer this request, then exit before the next one arrives so the + // pool is left holding a dead instance. + if (exitAfterReply) setTimeout(() => process.exit(0), 20) + } + if (replyDelayMs > 0) setTimeout(reply, replyDelayMs) + else reply() return } // Unknown request with an id: answer null so the client never stalls. @@ -172,3 +190,4 @@ function send(message: Record): void { // Keep the event loop alive. process.stdin.resume() +if (closeStdinAfterReply) setInterval(() => {}, 1000) diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts index 3fb9f804ac..8629502fd1 100644 --- a/packages/lsp/lsp-local/tests/host.spec.ts +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -3,8 +3,13 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { realpath } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { deadline } from '@deepseek-ai/dsh-timeout' import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local' +const execFileAsync = promisify(execFile) + let root: string let ws: string @@ -87,6 +92,19 @@ describe('readHostSource', () => { await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/) }) + it('rejects a FIFO with no writer without blocking in open', async () => { + const fifo = join(ws, 'pipe.ts') + await execFileAsync('mkfifo', [fifo]) + using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT') + await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/) + }) + + it('honors a pre-aborted source read before filesystem work', async () => { + const controller = new AbortController() + controller.abort(new Error('source read cancelled')) + await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/) + }) + it('treats the workspace root itself as inside, then rejects it as non-regular', async () => { // filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the // directory then fails the regular-file check. diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index f46d72571b..6019d870fa 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -7,9 +7,7 @@ import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } 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 @@ -31,9 +29,9 @@ afterEach(async () => { function makeInstance(env: Record = {}, overrides: Partial = {}): LspInstance { const instance = new LspInstance({ command: process.execPath, - args: ['--import', tsxLoader, fixtureServer], + args: [fixtureServer], cwd: ws, - env: { ...process.env as Record, TSX_TSCONFIG_PATH: repoTsconfig, ...env }, + env: { ...process.env as Record, ...env }, configuration: { setting: 42 }, initializationOptions: { init: true }, maxMessageBytes: 16_000_000, @@ -46,12 +44,12 @@ function makeInstance(env: Record = {}, overrides: Partial { +async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise { const source = await readHostSource('a.ts', ws, 4_000_000) return instance.query(query(operation), source, signal) } @@ -91,43 +89,43 @@ describe('LspInstance server-request handling', () => { 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(run(instance, 'definition')).resolves.toMatchObject({ kind: 'locations' }) + await expect(run(instance, 'goToDefinition')).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(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects a workspace/applyEdit request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) it('rejects an unknown server request but keeps serving', async () => { const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) }) }) describe('LspInstance query and abort', () => { it('sends includeDeclaration for references', async () => { const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) }) - await expect(run(instance, 'references')).resolves.toMatchObject({ kind: 'locations' }) + await expect(run(instance, 'findReferences')).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(run(instance, 'definition', controller.signal)).rejects.toThrow(/pre-abort/) + await expect(run(instance, 'goToDefinition', 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 = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) @@ -138,7 +136,7 @@ describe('LspInstance query and abort', () => { // down (its process closed) rather than left with an active request. const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 }) const controller = new AbortController() - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) @@ -159,7 +157,7 @@ describe('LspInstance query and abort', () => { + '}});' const instance = scriptInstance(script, { killGraceMs: 2_000 }) const controller = new AbortController() - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 300)) controller.abort(new Error('mid-flight')) await expect(pending).rejects.toThrow(/mid-flight/) @@ -173,7 +171,7 @@ describe('LspInstance query and abort', () => { // observed during that wait instead of hanging the tool-timeout signal. const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 }) const controller = new AbortController() - const pending = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 150)) controller.abort(new Error('handshake-abort')) await expect(pending).rejects.toThrow(/handshake-abort/) @@ -182,7 +180,7 @@ describe('LspInstance query and abort', () => { 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(run(instance, 'definition')).rejects.toThrow(/does not support definition/) + await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) }) it('propagates a server error response even when a signal is supplied (not an abort)', async () => { @@ -190,7 +188,20 @@ describe('LspInstance query and abort', () => { // without treating it as an abort. const instance = makeInstance({ LSP_FAKE_ERROR: '1' }) const controller = new AbortController() - await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/server refused/) + await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) + }) + + it('keeps a settled result but awaits teardown when didClose cannot be written', async () => { + const instance = makeInstance({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1', + }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ + kind: 'locations', + locations: [], + resolvedWorkspaceRoot: ws, + }) + expect(instance.dead).toBe(true) }) }) @@ -202,28 +213,28 @@ describe('LspInstance disposal', () => { LSP_FAKE_EXIT_DELAY_MS: '75', LSP_FAKE_EXIT_MARKER: marker, }, { shutdownTimeoutMs: 500 }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') }) it('is idempotent — a second dispose awaits close without error', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() await expect(instance.dispose()).resolves.toBeUndefined() }) it('rejects a query after disposal', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() - await expect(run(instance, 'definition')).rejects.toThrow(/disposed/) + await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' })) }) it('reports dead after the process closes', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') await instance.dispose() expect(instance.dead).toBe(true) }) @@ -232,7 +243,7 @@ describe('LspInstance disposal', () => { // 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 run(instance, 'definition') + await run(instance, 'goToDefinition') await expect(instance.dispose()).resolves.toBeUndefined() }) @@ -244,7 +255,7 @@ describe('LspInstance disposal', () => { + `writeFileSync(${JSON.stringify(marker)},String(helper.pid));` + RESPONDING_SERVER const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await run(instance, 'definition') + await run(instance, 'goToDefinition') const helperPid = Number(await readFile(marker, 'utf8')) try { const first = instance.dispose() @@ -259,7 +270,7 @@ describe('LspInstance disposal', () => { 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 = run(instance, 'definition', controller.signal) + const pending = run(instance, 'goToDefinition', controller.signal) await new Promise(resolve => setTimeout(resolve, 200)) controller.abort('a string reason, not an Error') await expect(pending).rejects.toThrow(/aborted/) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 3d06b7576b..826905ed26 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,9 +10,7 @@ import { deadline } from '@deepseek-ai/dsh-timeout' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' -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 @@ -32,8 +30,8 @@ afterEach(async () => { function fakeServer(fakeEnv: Record = {}, overrides: Partial = {}): LspLocalServerConfig { return { command: process.execPath, - args: ['--import', tsxLoader, fixtureServer], - env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv }, + args: [fixtureServer], + env: { ...fakeEnv }, extensionToLanguage: { '.ts': 'typescript' }, ...overrides, } @@ -79,7 +77,7 @@ describe('lsp-local end to end over a fake server', () => { it('resolves definition to normalized locations', async () => { const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) - const result = await ctx.lsp.query(query('definition')) + const result = await ctx.lsp.query(query('goToDefinition')) expect(result).toEqual({ kind: 'locations', locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], @@ -91,14 +89,14 @@ describe('lsp-local end to end over a fake server', () => { it('maps a LocationLink for implementation', async () => { const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } } const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) }) - const result = await ctx.lsp.query(query('implementation')) + const result = await ctx.lsp.query(query('goToImplementation')) expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] }) await ctx.fiber.dispose() }) it('returns references (server includes the declaration)', async () => { const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) }) - const result = await ctx.lsp.query(query('references')) + const result = await ctx.lsp.query(query('findReferences')) expect(result).toMatchObject({ kind: 'locations' }) if (result.kind !== 'locations') throw new Error('expected locations') expect(result.locations).toHaveLength(2) @@ -114,7 +112,7 @@ describe('lsp-local end to end over a fake server', () => { it('returns an empty locations result for a null definition', async () => { const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -126,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => { it('rejects a non-utf-16 position encoding at initialize', async () => { const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) await ctx.fiber.dispose() }) @@ -134,21 +132,21 @@ describe('lsp-local end to end over a fake server', () => { // A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a // permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it. const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) // A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one. - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) await ctx.fiber.dispose() }) it('rejects a server without transient-open sync (None)', async () => { const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/) await ctx.fiber.dispose() }) it('accepts openClose options sync', async () => { const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -162,25 +160,44 @@ describe('lsp-local end to end over a fake server', () => { const outside = join(root, 'out.ts') await writeFile(outside, 'x') const ctx = await mount({ LSP_FAKE_DEF: 'null' }) - await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/) + await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/) await ctx.fiber.dispose() }) it('serializes queries through one instance and runs them in order', async () => { const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) const results = await Promise.all([ - ctx.lsp.query(query('definition')), - ctx.lsp.query(query('definition')), - ctx.lsp.query(query('definition')), + ctx.lsp.query(query('goToDefinition')), + ctx.lsp.query(query('goToDefinition')), + ctx.lsp.query(query('goToDefinition')), ]) for (const result of results) expect(result).toMatchObject({ kind: 'locations' }) await ctx.fiber.dispose() }) + it('reads a queued query source only when its lifecycle starts', async () => { + const marker = join(root, 'opened.jsonl') + const ctx = await mount({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_REPLY_DELAY_MS: '300', + LSP_FAKE_OPEN_MARKER: marker, + }) + const first = ctx.lsp.query(query('goToDefinition')) + await waitFor(async () => (await markerLines(marker)).length === 1) + const second = ctx.lsp.query(query('goToDefinition')) + await writeFile(join(ws, 'a.ts'), 'const changed = 2\n') + await Promise.all([first, second]) + expect(await markerLines(marker)).toEqual([ + 'const x = 1\nconst y = x\n', + 'const changed = 2\n', + ]) + await ctx.fiber.dispose() + }) + it('aborts an in-flight query when the signal fires', async () => { const ctx = await mount({ LSP_FAKE_HANG: '1' }) const controller = new AbortController() - const pending = ctx.lsp.query(query('definition'), controller.signal) + const pending = ctx.lsp.query(query('goToDefinition'), controller.signal) controller.abort(new Error('caller cancelled')) await expect(pending).rejects.toThrow(/cancelled/) await ctx.fiber.dispose() @@ -190,7 +207,7 @@ describe('lsp-local end to end over a fake server', () => { const ctx = await mount({ LSP_FAKE_DEF: 'null' }) const controller = new AbortController() controller.abort(new Error('pre-aborted')) - await expect(ctx.lsp.query(query('definition'), controller.signal)).rejects.toThrow(/pre-aborted/) + await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/) await ctx.fiber.dispose() }) @@ -201,22 +218,22 @@ describe('lsp-local end to end over a fake server', () => { command: process.execPath, args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'], }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/FATAL: boom/) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/) await ctx.fiber.dispose() }) it('classifies a timeout deadline as the abort reason', async () => { const ctx = await mount({ LSP_FAKE_HANG: '1' }) using d = deadline(undefined, 50, 'TEST_TIMEOUT') - await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/) + await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/) await ctx.fiber.dispose() }) it('fails the active query when the server crashes on open, and replaces it next query', async () => { const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang). - await expect(ctx.lsp.query(query('definition'))).rejects.toThrow() + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() await ctx.fiber.dispose() }) @@ -225,10 +242,10 @@ describe('lsp-local end to end over a fake server', () => { // instance in the pool. The next query must evict-and-replace it and still succeed, rather than // failing once on the closed connection first. const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) - expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) // Wait past the fixture's post-reply exit so the pooled instance is observably dead. await new Promise(resolve => setTimeout(resolve, 60)) - expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' }) + expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) await ctx.fiber.dispose() }) @@ -237,11 +254,11 @@ describe('lsp-local end to end over a fake server', () => { // are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance. const ctx = await mount({ LSP_FAKE_DEF: 'null' }) const controller = new AbortController() - const pending = ctx.lsp.query(query('definition'), controller.signal) + const pending = ctx.lsp.query(query('goToDefinition'), controller.signal) controller.abort(new Error('mid-read cancel')) await expect(pending).rejects.toThrow(/mid-read cancel/) // A subsequent live query still works, proving no half-created instance poisoned the pool. - expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) await ctx.fiber.dispose() }) @@ -251,8 +268,8 @@ describe('lsp-local end to end over a fake server', () => { await writeFile(join(ws2, 'a.ts'), 'const z = 2\n') const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) const [r1, r2] = await Promise.all([ - ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }), - ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }), + ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }), + ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }), ]) expect(r1).toMatchObject({ kind: 'locations' }) expect(r2).toMatchObject({ kind: 'locations' }) @@ -261,7 +278,7 @@ describe('lsp-local end to end over a fake server', () => { it('disposes cleanly, terminating a server that ignores shutdown', async () => { const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 }) - await ctx.lsp.query(query('definition')) + await ctx.lsp.query(query('goToDefinition')) await expect(ctx.fiber.dispose()).resolves.toBeUndefined() }) @@ -280,3 +297,23 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) }) + +/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */ +async function markerLines(path: string): Promise { + try { + const text = await readFile(path, 'utf8') + return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } +} + +/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */ +async function waitFor(condition: () => Promise, timeoutMs = 3000): Promise { + const started = Date.now() + while (!await condition()) { + if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 65a22bfb5b..8746b7a903 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -6,6 +6,7 @@ import { Context } from 'cordis' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' let root: string let ws: string @@ -22,7 +23,7 @@ afterEach(async () => { }) function query(): LspQueryRequest { - return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } + return { operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } } /** Wrap one server entry in the plugin's named server table. */ @@ -91,6 +92,30 @@ describe('lsp-local provider resolution', () => { await ctx.fiber.dispose() }) + it('rejects a nonpositive byte cap at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('bad-cap', { + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + maxDocumentBytes: 0, + }))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/) + await ctx.fiber.dispose() + }) + + it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('bad-timer', { + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + [name]: MAX_TIMER_DELAY_MS + 1, + }))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`)) + await ctx.fiber.dispose() + }) + it('rejects an absolute command that is not executable at load', async () => { const notExe = join(root, 'not-exe.txt') await writeFile(notExe, 'plain text, not executable') diff --git a/packages/lsp/lsp-local/tests/translate.spec.ts b/packages/lsp/lsp-local/tests/translate.spec.ts index 7727112089..a68afebdd5 100644 --- a/packages/lsp/lsp-local/tests/translate.spec.ts +++ b/packages/lsp/lsp-local/tests/translate.spec.ts @@ -13,9 +13,9 @@ 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('goToDefinition')).toBe('textDocument/definition') + expect(requestMethod('findReferences')).toBe('textDocument/references') + expect(requestMethod('goToImplementation')).toBe('textDocument/implementation') expect(requestMethod('hover')).toBe('textDocument/hover') }) }) @@ -27,9 +27,9 @@ describe('supportsOperation', () => { 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, 'goToDefinition')).toBe(true) + expect(supportsOperation(caps, 'findReferences')).toBe(true) + expect(supportsOperation(caps, 'goToImplementation')).toBe(false) expect(supportsOperation(caps, 'hover')).toBe(false) }) }) @@ -66,9 +66,9 @@ describe('negotiatePositionEncoding', () => { }) describe('normalizeLocations', () => { - it('returns empty for null and undefined', () => { + it('returns empty only for the protocol no-result value null', () => { expect(normalizeLocations(null)).toEqual([]) - expect(normalizeLocations(undefined)).toEqual([]) + expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) }) it('maps a single Location', () => { @@ -100,6 +100,13 @@ describe('normalizeLocations', () => { it('rejects a Location whose range positions are malformed', () => { expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/) }) + + it('rejects negative and fractional position coordinates', () => { + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }])) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }])) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) }) describe('normalizeHover', () => { @@ -107,6 +114,10 @@ describe('normalizeHover', () => { expect(normalizeHover(null)).toBeNull() }) + it('rejects a missing hover result', () => { + expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + it('reads MarkupContent value and keeps a range', () => { expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE })) .toEqual({ contents: '# H', range: RANGE }) @@ -130,8 +141,9 @@ describe('normalizeHover', () => { 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 MarkupContent with a non-string value', () => { + expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) }) it('rejects a non-object payload', () => { @@ -143,11 +155,19 @@ describe('normalizeHover', () => { expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/) }) + it('rejects a malformed MarkedString array member', () => { + expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + expect(() => normalizeHover({ contents: [null] })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + 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' }) + it('rejects a malformed range instead of silently dropping it', () => { + expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) }) }) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index 300ce7d318..8ba61c0717 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -81,7 +81,7 @@ function locations(result: LspQueryResult): readonly { uri: string }[] { describe('real typescript-language-server', () => { it('resolves the definition of a call site to its declaration', async () => { // `export const text = describe(c)` (line 15): `describe` begins at column 21. - const result = await ctx.lsp.query(at('definition', 15, 22)) + const result = await ctx.lsp.query(at('goToDefinition', 15, 22)) const locs = locations(result) expect(locs.length).toBeGreaterThanOrEqual(1) expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true) @@ -89,7 +89,7 @@ describe('real typescript-language-server', () => { it('finds references to a symbol including its declaration', async () => { // References to `describe` from its declaration (line 10, col 17). - const result = await ctx.lsp.query(at('references', 10, 17)) + const result = await ctx.lsp.query(at('findReferences', 10, 17)) const locs = locations(result) // At least the declaration plus the call site. expect(locs.length).toBeGreaterThanOrEqual(2) @@ -97,7 +97,7 @@ describe('real typescript-language-server', () => { it('resolves implementations of an interface', async () => { // Implementations of `Shape` (line 1, col 18) → Circle. - const result = await ctx.lsp.query(at('implementation', 1, 18)) + const result = await ctx.lsp.query(at('goToImplementation', 1, 18)) const locs = locations(result) expect(locs.length).toBeGreaterThanOrEqual(1) }, 60_000) diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index 2f7fc6ff9f..df9ced7dc3 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -10,7 +10,7 @@ This package is the interface third of the LSP capability: | `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers | | `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | -The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. +The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. ## Service API (`ctx.lsp`) @@ -25,7 +25,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner ## Vocabulary -`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. +`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`. ## Model Experience diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts index e00005acde..d7b1a80d01 100644 --- a/packages/lsp/lsp/src/index.ts +++ b/packages/lsp/lsp/src/index.ts @@ -1,6 +1,7 @@ /** * The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query, - * order-independent selection over normalized definition/references/implementation/hover queries. + * order-independent selection over normalized goToDefinition/findReferences/goToImplementation/ + * hover queries. * * A provider reserves a branded id and an exclusive set of file extensions atomically: * {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an @@ -42,8 +43,9 @@ declare module 'cordis' { /** * Structured LSP failure. Extends {@link HarnessError} with a stable `code` - * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`, …) that - * callers route on instead of parsing `message`. + * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, + * `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of + * parsing `message`. */ export class LspError extends HarnessError {} diff --git a/packages/lsp/lsp/src/types.ts b/packages/lsp/lsp/src/types.ts index d1ae71dccd..d0c84f606d 100644 --- a/packages/lsp/lsp/src/types.ts +++ b/packages/lsp/lsp/src/types.ts @@ -14,7 +14,7 @@ import type { LspProviderId } from './brand.ts' * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are * deliberately deferred (they need different schemas). */ -export type LspOperation = 'definition' | 'references' | 'implementation' | 'hover' +export type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' /** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ export interface LspPosition { @@ -73,9 +73,9 @@ export interface LspHover { } /** - * The closed result union. Navigation operations (`definition`, `references`, `implementation`) - * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` - * to exhaustiveness so a new arm breaks compilation until handled. + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. * * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that @@ -88,8 +88,9 @@ export type LspQueryResult = /** * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link - * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references` - * always includes declarations — the provider enforces this internally; callers get no flag. + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. */ export interface LspProvider { /** Stable provider identity, reserved atomically with the extension mappings. */ diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts index 8561891df1..77ea9687c7 100644 --- a/packages/lsp/lsp/tests/lsp.spec.ts +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -39,7 +39,7 @@ async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> { const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } } -function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters[0] { +function query(filePath: string, operation: LspProviderQuery['operation'] = 'goToDefinition'): Parameters[0] { return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' } } diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index a3ffb9d871..4a844d2537 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -6,7 +6,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In ## 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. +`lsp` accepts `operation` (`goToDefinition` | `findReferences` | `goToImplementation` | `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. `findReferences` 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 relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; 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. @@ -15,7 +15,7 @@ The tool requires the workspace root from the session `header.cwd`, with no fall | Key | Default | Meaning | |---|---|---| | `maxLocations` | `100` | Largest number of rendered locations before an omission marker. | -| `maxHoverChars` | `16000` | Largest hover length in characters, applied after normalization. | +| `maxResultChars` | `16000` | Largest complete rendered result, including truncation metadata. | | `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 @@ -29,7 +29,7 @@ One system-prompt section (order 112) positions LSP as a precision aid with the ##### Verbatim guidance ```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. +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. findReferences always includes the declaration. ``` #### Token effect @@ -58,11 +58,11 @@ Prefix-stable while the visible tool definition and order are unchanged; registr #### 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. +File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. Empty results use distinct `No results.` / `No hover information.` lines. #### Token effect -Capped per tool result by the two limits above. +Capped per tool result by `maxResultChars`, with `maxLocations` additionally bounding navigation item count. #### KV Cache effect diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 4559bbd1b5..abedad1e53 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,6 +1,6 @@ { "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", + "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", "version": "0.0.1", "private": true, "type": "module", @@ -25,6 +25,7 @@ "@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-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-lsp-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index 3a37b7b6ed..c298bdffb8 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -1,9 +1,10 @@ /** * 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. + * (`goToDefinition`/`findReferences`/`goToImplementation`/`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 @@ -12,13 +13,14 @@ 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 { assertNever, 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 { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { - DEFAULT_MAX_HOVER_CHARS, DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, formatHover, formatLocations, LSP_OPERATIONS, @@ -28,8 +30,8 @@ import { import { sessionCwd } from './session-cwd.ts' export { - DEFAULT_MAX_HOVER_CHARS, DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, formatHover, formatLocations, LSP_OPERATIONS, @@ -50,22 +52,22 @@ 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.' + '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. findReferences 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 + /** Largest complete rendered result in characters, including truncation metadata (default 16000). */ + maxResultChars?: number /** Tool-call timeout budget in ms (default 60000). */ timeoutMs?: number } export const Config: z = 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), + maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS), + timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_LSP_TOOL_TIMEOUT_MS), }) type ResolvedConfig = Required @@ -78,21 +80,21 @@ type ResolvedConfig = Required 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) + assertPositiveInteger('maxResultChars', resolved.maxResultChars) + assertTimer('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.', + 'Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.', parameters: { operation: { type: 'string', required: true, enum: [...LSP_OPERATIONS], - description: 'definition, references, implementation, or hover.', + description: 'goToDefinition, findReferences, goToImplementation, 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.' }, @@ -116,9 +118,12 @@ export function apply(ctx: Context, config: Config): void { // Relativize against the provider's canonical workspace root (which its file: URIs are // relative to), not the session cwd: a symlinked cwd would otherwise misclassify every // in-workspace location as external and render it as an absolute path. - return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations) }] + return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }] case 'hover': - return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }] + return [{ type: 'text', text: formatHover(result.hover, resolved.maxResultChars) }] + /* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */ + default: + return assertNever(result, 'tool-lsp result') } }, presentCall: presentLspCall, @@ -131,3 +136,10 @@ function assertPositiveInteger(name: string, value: number): void { throw new Error(`tool-lsp: ${name} must be a positive integer`) } } + +/** Reject a timer value Node would clamp instead of scheduling as configured. */ +function assertTimer(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } +} diff --git a/packages/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts index 0051adc719..b6341ae407 100644 --- a/packages/lsp/tool-lsp/src/render.ts +++ b/packages/lsp/tool-lsp/src/render.ts @@ -1,8 +1,8 @@ /** * 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. + * conversion, workspace-grouped location rendering with `file:`-URI resolution, complete-result + * 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 */ @@ -12,13 +12,13 @@ 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'] +export const LSP_OPERATIONS: readonly LspOperation[] = ['goToDefinition', 'findReferences', 'goToImplementation', '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 +/** Default cap on the complete rendered tool result, including truncation metadata. */ +export const DEFAULT_MAX_RESULT_CHARS = 16_000 /** Validated `lsp` arguments after coordinate checks. */ export interface LspToolInput { @@ -75,18 +75,20 @@ function oneBased(value: number, name: string): number { * 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. + * appends an omission marker when it truncates by count, then applies the complete result cap. * @param locations - the seam's locations (possibly empty). * @param workspaceRoot - the canonical workspace root for relativizing `file:` paths. * @param maxLocations - the cap before truncation. + * @param maxResultChars - the complete rendered-text cap, including truncation metadata. * @returns the rendered text; a distinct no-result line when there are none. */ export function formatLocations( locations: readonly LspLocation[], workspaceRoot: string, maxLocations: number, + maxResultChars: number, ): string { - if (locations.length === 0) return 'No results.' + if (locations.length === 0) return boundResult('No results.', maxResultChars, 'locations') const shown = locations.slice(0, maxLocations) const omitted = locations.length - shown.length const grouped = new Map() @@ -103,20 +105,26 @@ export function formatLocations( if (omitted > 0) { lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`) } - return lines.join('\n') + return boundResult(lines.join('\n'), maxResultChars, 'locations') } /** - * Render a hover result, applying `maxHoverChars` last and marking truncation. + * Render a hover result, applying `maxResultChars` last and keeping its marker within the cap. * @param hover - the normalized hover, or `null` for no hover. - * @param maxHoverChars - the cap applied after normalization. + * @param maxResultChars - the complete rendered-text cap, including truncation metadata. * @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).` +export function formatHover(hover: LspHover | null, maxResultChars: number): string { + const text = hover === null ? 'No hover information.' : hover.contents + return boundResult(text, maxResultChars, 'hover') +} + +/** Bound a complete rendered result, including the truncation notice itself. */ +function boundResult(text: string, maxChars: number, label: string): string { + if (text.length <= maxChars) return text + const notice = `\n… ${label} truncated (limit ${maxChars} characters).` + if (notice.length >= maxChars) return notice.slice(0, maxChars) + return `${text.slice(0, maxChars - notice.length)}${notice}` } /** diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 5c98d9fff0..8b4cb79de6 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -78,7 +78,7 @@ function call(ctx: Context, args: unknown) { describe('tool-lsp integration', () => { 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 }) + const result = await call(ctx, { operation: 'goToDefinition', 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() @@ -86,7 +86,7 @@ describe('tool-lsp integration', () => { 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 }) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 }) expect(result.isError).toBe(true) expect(result.error?.code).toBe('TOOL_TIMEOUT') await ctx.fiber.dispose() diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts index 88b02a1fd5..a277539879 100644 --- a/packages/lsp/tool-lsp/tests/render.spec.ts +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { pathToFileURL } from 'node:url' import { join } from 'node:path' import { - DEFAULT_MAX_HOVER_CHARS, DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, formatHover, formatLocations, LSP_OPERATIONS, @@ -79,52 +79,63 @@ describe('renderUri', () => { describe('formatLocations', () => { it('renders a no-result line for an empty list', () => { - expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS)).toBe('No results.') + expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).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) + const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS) 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) + const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS) 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) + const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS) expect(text).toContain('1 more location omitted (limit 1).') }) + + it('caps the complete location text even when one URI is enormous', () => { + const maxResultChars = 80 + const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars) + expect(text).toHaveLength(maxResultChars) + expect(text).toContain('locations truncated') + }) }) describe('formatHover', () => { it('renders a no-result line for null', () => { - expect(formatHover(null, DEFAULT_MAX_HOVER_CHARS)).toBe('No hover information.') + expect(formatHover(null, DEFAULT_MAX_RESULT_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```') + expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_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).') + it('caps the complete hover text including its truncation marker', () => { + const text = formatHover({ contents: 'a'.repeat(100) }, 60) + expect(text).toHaveLength(60) + expect(text).toContain('hover truncated (limit 60 characters).') + }) + + it('still honors a cap smaller than the truncation marker', () => { + expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10) }) }) 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({ + expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({ card: 'generic', kind: 'search', - title: 'LSP references a.ts:3:7', + title: 'LSP findReferences a.ts:3:7', locations: [{ path: 'a.ts', line: 3 }], }) }) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 577fa07396..37c995a3a6 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -5,6 +5,7 @@ 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' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' /** A scripted provider recording queries; `respond` yields the result or throws. */ function stubProvider( @@ -76,7 +77,7 @@ describe('tool-lsp registration', () => { 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']) + expect(schema.properties.operation.enum).toEqual(['goToDefinition', 'findReferences', 'goToImplementation', 'hover']) }) it('has no default export (namespace plugin shape)', () => { @@ -86,16 +87,28 @@ describe('tool-lsp registration', () => { it('rejects a non-positive config value at load', async () => { await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/) }) + + it('rejects a timeout above Node timer range at load', async () => { + await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(/timeoutMs/) + expect(() => { + ToolLsp.apply(new Context(), { + maxLocations: 100, + maxResultChars: 16_000, + timeoutMs: MAX_TIMER_DELAY_MS + 1, + }) + }).toThrow(/timeoutMs/) + }) }) 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') + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws') expect(result.isError).toBe(false) expect(provider.seen[0]).toMatchObject({ - operation: 'definition', + operation: 'goToDefinition', filePath: 'a.ts', position: { line: 2, character: 4 }, workspaceRoot: '/ws', @@ -104,7 +117,7 @@ describe('tool-lsp execution', () => { 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') + const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws') expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) }) @@ -118,7 +131,7 @@ describe('tool-lsp execution', () => { resolvedWorkspaceRoot: '/real/ws', })) const { ctx } = await mount(provider) - const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/alias') + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias') expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' }) expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) }) @@ -131,14 +144,14 @@ describe('tool-lsp execution', () => { 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) + const result = await call(ctx, { operation: 'goToDefinition', 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') + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') expect(result.isError).toBe(true) expect(result.error?.code).toBe('LSP_UNAVAILABLE') }) @@ -161,7 +174,7 @@ describe('tool-lsp execution', () => { }, } const { ctx } = await mount(provider) - await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + await call(ctx, { operation: 'goToDefinition', 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) diff --git a/packages/lsp/tool-lsp/tsconfig.json b/packages/lsp/tool-lsp/tsconfig.json index be656effd2..e53735f570 100644 --- a/packages/lsp/tool-lsp/tsconfig.json +++ b/packages/lsp/tool-lsp/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/timeout" + }, { "path": "../lsp" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fa837631f..c74251e7ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1512,6 +1512,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../timeout/timeout-policy From bc60b9ffe808f422d7563d689a1a8c5cfede6cd6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:52:15 +0800 Subject: [PATCH 15/15] fix(lsp): cancel blocked document opens --- .../2026-07-15-lsp-capability-seam.i18n.yaml | 4 +- .../2026-07-15-lsp-capability-seam.md | 4 +- .../2026-07-15-lsp-capability-seam.zh.md | 4 +- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/src/instance.ts | 13 +++-- .../lsp/lsp-local/tests/fixture-server.ts | 18 ++++++- packages/lsp/lsp-local/tests/instance.spec.ts | 49 +++++++++++++++++++ 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 82433d5fff..70b98f0cf1 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: 91b15e8f9ff044d2c438c87040f9fc19a8dabc6a -2026-07-15-lsp-capability-seam.zh.md: 39e63370241e8bbeb93ea7bb81fbd951fe807b19 +2026-07-15-lsp-capability-seam.md: 7265b04ac9b2f83764bdd13f07b2d3404c4c1708 +2026-07-15-lsp-capability-seam.zh.md: 10e8956005045d0934dd9dada5718b85a34cda3f diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md index 91b15e8f9f..7265b04ac9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -119,7 +119,7 @@ The `read` tool is unsuitable source because its output is windowed, numbered, t The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`. 1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs. -2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. +2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it. 3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. 4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. @@ -177,7 +177,7 @@ The local provider trusts its configured server and claims no sandbox confinemen - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. - Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. - Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`. -- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection. +- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection. - Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. - Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal. - Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 39e6337024..10e8956005 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -119,7 +119,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p 本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。 -2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。 +2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消;写入失败或遭取消会使实例失效,并等待有界进程终止完成,池才能复用它。 3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 @@ -177,7 +177,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 - 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 - 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。 -- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 +- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 - 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 - 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。 - 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 66568b17d1..85cc7945dd 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. -- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. +- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 9d327fb795..74381c1483 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -138,9 +138,16 @@ export class LspInstance { try { /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ if (signal?.aborted) throw abortError(signal) - await this.connection.notify('textDocument/didOpen', { - textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, - }) + try { + await abortable(this.connection.notify('textDocument/didOpen', { + textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, + }), signal) + } catch (error) { + // A canceled backpressured write or failed stdin leaves the protocol stream unusable before + // `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance. + await this.startTeardown() + throw error + } opened = true const payload = await this.sendRequest(request.operation, uri, request.position, signal) return this.normalize(request.operation, payload) diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 8dec856274..c418ada519 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -14,6 +14,9 @@ * simulating a server that dies while idle so the pool holds a dead instance (eviction test). * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds. * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. + * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received. + * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized. + * - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification. * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). @@ -35,6 +38,9 @@ const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) const openMarker = process.env.LSP_FAKE_OPEN_MARKER +const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER +const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1' +const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1' const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) const exitMarker = process.env.LSP_FAKE_EXIT_MARKER @@ -137,7 +143,13 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (onOpen !== undefined) emitServerRequest(onOpen) return } - if (method === 'textDocument/didClose' || method === 'initialized') return + if (method === 'initialized') { + if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n') + if (pauseStdinAfterInitialized) process.stdin.pause() + if (closeStdinAfterInitialized) closeSync(0) + return + } + if (method === 'textDocument/didClose') return if (method?.startsWith('textDocument/')) { if (hang) return const reply = (): void => { @@ -190,4 +202,6 @@ function send(message: Record): void { // Keep the event loop alive. process.stdin.resume() -if (closeStdinAfterReply) setInterval(() => {}, 1000) +if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) { + setInterval(() => {}, 1000) +} diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 6019d870fa..328c8b7313 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -178,6 +178,40 @@ describe('LspInstance query and abort', () => { await instance.dispose() }) + it('terminates when abort interrupts a backpressured didOpen write', async () => { + // The fixture consumes initialized, then stops reading. A document larger than the stdio pipe + // keeps didOpen's write callback pending until cancellation forces bounded process teardown. + await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) + const marker = join(root, 'initialized.log') + const instance = makeInstance({ + LSP_FAKE_INITIALIZED_MARKER: marker, + LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1', + }, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }) + const controller = new AbortController() + const pending = run(instance, 'goToDefinition', controller.signal) + await waitForFile(marker) + // Let the client enter the large didOpen write after the fixture has paused stdin. + await new Promise(resolve => setTimeout(resolve, 100)) + controller.abort(new Error('didOpen-abort')) + await expect(pending).rejects.toThrow(/didOpen-abort/) + expect(instance.dead).toBe(true) + }) + + it('terminates when stdin fails during the didOpen write', async () => { + // Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose; + // the instance must still become dead so its provider can replace it. + await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) + const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }) + await expect(run(instance, 'goToDefinition')).rejects.toThrow() + expect(instance.dead).toBe(true) + }) + 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(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) @@ -287,3 +321,18 @@ function processAlive(pid: number): boolean { throw error } } + +/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */ +async function waitForFile(path: string, timeoutMs = 3000): Promise { + const started = Date.now() + for (;;) { + try { + await readFile(path) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +}