# LSP / 诊断 / 上下文增强

> 源码基线：`eec0843ce422`。本章追踪的是“编辑一个 TypeScript 文件后”的**典型源码路径**，不是实际启动 language server 的运行记录；具体可用 server 取决于本机配置与依赖。

## 0. 本章学习目标

学完本章，你应该能：

- 画出文件工具、`LSP.Service`、LSP client、language server 和 Agent loop 的关系。
- 解释为什么 LSP 是反馈层，而不是 Agent 核心循环或编译器替代品。
- 追踪一次 edit 后的 `touchFile -> getClients -> open/change -> wait -> diagnostics -> report`。
- 区分 push 与 pull diagnostics，以及 `document` 与 `full` 等待模式。
- 说明 client 复用、in-flight 去重、broken 标记解决了什么问题。
- 识别 LSP 被禁用、server 不匹配、启动失败、等待超时和陈旧诊断等失败边界。

## 1. 一句话讲明白

LSP 是 OpenCode 的“IDE 反馈层”：文件落盘后，它让匹配的 language server 重新看到该文档，等待一小段时间收集诊断，再把有限的 error 文本放回工具结果，让 Agent 有机会继续修。

本章的中心问题是：

> 文件写成功只证明 I/O 成功；OpenCode 如何快速知道这次修改是否引入了语言级错误，又如何避免 LSP 故障反过来阻断所有编辑？

## 2. 先看全图：LSP 在哪里，不在哪里

```text
EditTool / WriteTool
        |
        | 文件已经落盘
        v
   LSP.touchFile(file, mode)
        |
        v
   getClients(file)
   - 扩展名匹配
   - 计算项目 root
   - 复用或启动 client
        |
        v
 client.notify.open
 didOpen 或 didChange
        |
        v
 push notification <---- Language Server ---- pull request
        |                                      |
        +---------------+----------------------+
                        v
               client diagnostics cache
                        |
                        v
             LSP.diagnostics + report
                        |
                        v
               tool output -> 下一轮模型
```

边界先说清：

- **通用机制**：变更后通知分析器、等待新结果、聚合、限流、反馈。
- **OpenCode 产品层**：按 instance 缓存 client、Effect service、Bus event、push/pull 兼容、tool output 格式。
- **LSP 不负责**：决定改什么、批准能否改、运行单元测试、证明业务正确。

`LSP.Interface` 还暴露 hover、definition、references、symbols 和调用层级查询，说明它也能提供语义上下文；本章的具体旅程只跟踪编辑后的 diagnostics，因为这是文件工具自动进入的路径。接口见 `packages/opencode/src/lsp/lsp.ts:123-138`。

## 3. 最小机制：把编辑后的文件重新交给分析器

忽略 JSON-RPC 和多语言细节，最小闭环是：

```ts
async function diagnoseAfterEdit(file) {
  const clients = await clientsFor(file)
  for (const client of clients) {
    const version = await client.openOrChange(file)
    await client.waitForDiagnostics(file, version)
  }
  return onlyErrorsAndLimit(merge(clients.map(c => c.diagnostics)))
}
```

为什么不能只读一次 `client.diagnostics`？因为文件刚改完时，缓存可能仍是旧版本。`touchFile` 先发文档通知，再按 mode 等待 push 或 pull 结果，正是在处理这个时间差。

Java 开发者可以把它类比为一个长期运行的 `LanguageIntelligenceService`，但不要类比成每次调用 `javac`：LSP client 与 server 会跨多次编辑复用，并通过 JSON-RPC 增量保持文档状态。

## 4. 三层对象：server 配置、client 连接、service 路由

| 层 | 真实标识 | 职责 | 生命周期 |
| --- | --- | --- | --- |
| server 描述 | `LSPServer.Info` | 支持哪些扩展、怎样找 root、怎样 spawn | 配置/内置注册 |
| client | `LSPClient.Info` | JSON-RPC 连接、文档版本、诊断缓存 | 按 server + root 复用 |
| service | `LSP.Service` | 根据文件选 client，聚合诊断和语义查询 | instance 级 |

`LSP.state` 在配置允许时加载内置 server，再应用禁用、覆盖或自定义 command/env/initialization。若 `cfg.lsp` 为假值，则所有 LSP 都禁用。见 `packages/opencode/src/lsp/lsp.ts:148-199`。

## 5. 一条具体源码旅程：edit 后诊断 `src/config.ts`

假设 `EditTool` 已经成功修改 `/workspace/project/src/config.ts`。这是教学输入，不代表本章真的改过该文件。

### 5.1 文件先落盘，LSP 后介入

`EditTool` 在写入、格式化、发布事件并更新 metadata 之后才调用：

```ts
yield* lsp.touchFile(filePath, "document")
const diagnostics = yield* lsp.diagnostics()
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
```

路径：`packages/opencode/src/tool/edit.ts:151-197`

这建立了明确的因果顺序：language server 读取的是最终落盘且可能已格式化的文件，而不是模型最初提交的 `newString`。

### 5.2 getClients 先判断“谁能理解这个文件”

`getClients(file)` 首先拒绝 instance 外文件，然后取扩展名，遍历 server：

1. server 有 extensions 且不包含 `.ts`：跳过。
2. `server.root(file, ctx)` 找不到 root：跳过。
3. `(root + server.id)` 已在 `broken`：跳过。
4. 已有相同 root/server client：复用。
5. 正在 spawn：等待同一个 in-flight Promise。
6. 否则 schedule 一次 spawn + initialize。

路径：`packages/opencode/src/lsp/lsp.ts:211-299`。

<!-- source-ref path="packages/opencode/src/lsp/lsp.ts" lines="211-299" title="getClients：按文件懒启动并复用 LSP client" note="重点观察 clients、spawning、broken 三个状态如何避免重复启动和重复失败。" -->

`spawning` 解决并发触发下的重复启动；`broken` 让当前 instance 不再反复尝试同一 root/server 组合。后者提高稳定性，但也意味着环境在运行中被修好后，不一定会自动重试。

### 5.3 schedule 把进程变成 JSON-RPC client

server spawn 失败或返回空值会标记 broken。进程启动后，`LSPClient.create` 若初始化失败，也会停止进程并标记 broken。见 `packages/opencode/src/lsp/lsp.ts:219-257`。

client 用 server stdout/stdin 建立 JSON-RPC message connection：

```ts
const connection = createMessageConnection(
  new StreamMessageReader(input.server.process.stdout as any),
  new StreamMessageWriter(input.server.process.stdin as any),
)
```

路径：`packages/opencode/src/lsp/client.ts:141-155`

initialize 有 45 秒 timeout，并声明文档同步、动态注册诊断和 related document 等 capability。失败被包装成 `InitializeError`。见 `packages/opencode/src/lsp/client.ts:18-23`、`packages/opencode/src/lsp/client.ts:246-305`。

### 5.4 touchFile 选择 didOpen 还是 didChange

`touchFile` 对每个 client 调 `notify.open`。这个名字容易误导：它既可能 open，也可能 change。

首次见到文件时：

- 读取磁盘文本；
- 发送 `workspace/didChangeWatchedFiles`，类型为 created；
- 清空该文件的 push/pull cache；
- 发送 `textDocument/didOpen`，version = 0；
- 把文本和版本记入 `files`。

再次触碰时：

- 发送 watched-files changed；
- version + 1；
- 发送 `textDocument/didChange`；
- 若 server 要求 incremental sync，仍以“覆盖旧全文范围”的单个 change 发送新全文。

路径：`packages/opencode/src/lsp/client.ts:594-669`。

第二次 touch 不会预先清空 diagnostics，因为 clangd 等 server 对无内容变化的触碰可能不重新发布；贸然清空会把仍有效的错误丢掉。这个取舍由注释和控制流直接说明，见 `packages/opencode/src/lsp/client.ts:603-608`。

### 5.5 document 模式同时兼容 push 与 pull

`touchFile(file, "document")` 记录 `after` 时间和文档 version，再调用 `waitForDiagnostics`。document 等待上限为 5 秒，full 为 10 秒，单次 pull request 为 3 秒。见 `packages/opencode/src/lsp/client.ts:18-23`、`packages/opencode/src/lsp/lsp.ts:346-366`。

push 路径：server 发 `textDocument/publishDiagnostics`，client 记录时间/version并更新 cache。见 `packages/opencode/src/lsp/client.ts:191-208`。

pull 路径：client 主动请求 `textDocument/diagnostic`；若 server 动态注册多个 identifier，会并行发出请求，一旦已有一批产生当前文件诊断即可解除等待，较慢结果继续在后台合并。见 `packages/opencode/src/lsp/client.ts:332-366`、`packages/opencode/src/lsp/client.ts:451-466`。

`waitForDocumentDiagnostics` 在 pull 未匹配时，会与 fresh push、capability registration 变化竞争，直到结果或超时。见 `packages/opencode/src/lsp/client.ts:503-560`。

### 5.6 聚合后只把有限 error 交给模型

每个 client 内部先合并、去重 push 与 pull diagnostics。service 再把所有已启动 client 的 map 按文件合并。见 `packages/opencode/src/lsp/client.ts:167-184`、`packages/opencode/src/lsp/client.ts:671-676`、`packages/opencode/src/lsp/lsp.ts:368-379`。

`Diagnostic.report` 只保留 severity = 1 的 error，每个文件最多输出 20 条，并把 0-based line/character 转成 1-based 文本位置。见 `packages/opencode/src/lsp/diagnostic.ts:3-26`。

因此：

- warning/info/hint 仍可能存在于 metadata 的 diagnostics map；
- 但 `EditTool` 拼给模型的文本 block 只包含 error；
- “没有 block”既可能是没有 error，也可能是没有可用 client、等待未得到新结果或 LSP 已被禁用。

## 6. WriteTool 为什么会看到“其他文件”的错误

`WriteTool` 同样调用 `touchFile(filepath, "document")`，然后遍历整个聚合 diagnostics map：当前文件优先报告，其他有 error 的文件最多报告五个。见 `packages/opencode/src/tool/write.ts:18-18`、`packages/opencode/src/tool/write.ts:74-90`。

这对创建公共类型或配置文件很有用：一个文件的变化可能让引用方报错。但它不是完整项目诊断保证：

- `document` mode 重点等待当前文件，不等同于完整 workspace 检查；
- 只有已经启动的 clients 会被 `runAll` 聚合；
- 文本输出还限制了其他文件数量。

若需要构建级确信，仍应运行测试、编译或专门的检查命令。

## 7. 语义上下文：同一 service 还能回答什么

`LSP.Service` 把位置查询路由给适用 clients：

- `hover` → `textDocument/hover`
- `definition` → `textDocument/definition`
- `references` → `textDocument/references`
- `implementation` → `textDocument/implementation`
- `documentSymbol / workspaceSymbol`
- incoming / outgoing call hierarchy

路径：`packages/opencode/src/lsp/lsp.ts:381-482`。

多数查询失败会被降级为 `null` 或空数组，再把多个 client 结果拍平。这体现同一设计：LSP 是增强信息，不应轻易让整个 Agent 工作流崩溃。

证据边界：本章列出的 sourceFiles 证明 service 能力与 JSON-RPC 请求；模型怎样把这些能力作为具体 tool 使用，不在本章这条自动 diagnostics 旅程内。

## 8. 失败路径：best-effort 到底意味着什么

| 情况 | 源码行为 | 对文件编辑的影响 |
| --- | --- | --- |
| LSP 全局禁用 | server registry 为空 | 编辑成功，无诊断 |
| 扩展名或 root 不匹配 | `getClients` 返回空 | 编辑成功，无诊断 |
| 文件位于 instance 外 | `getClients` 返回空 | LSP 不处理该文件 |
| server spawn 返回空/抛错 | 标记 broken，记录日志 | 编辑成功，本 instance 通常不再重试该组合 |
| client initialize 失败/超时 | 停止进程，标记 broken | 编辑成功，无该 client 诊断 |
| touch/open/wait 任一步抛错 | `touchFile` catch 并记录日志 | 编辑成功，诊断可能缺失或仍是缓存值 |
| push/pull 等待超时 | wait 返回 | 编辑继续，不能保证拿到本次最新结果 |
| server 返回 warning/info | metadata 可保留 | `Diagnostic.report` 不把它拼入错误 block |
| error 超过 20 条 | 截断并显示剩余数量 | Agent 先看到前 20 条 |

<!-- source-ref path="packages/opencode/src/lsp/lsp.ts" lines="346-379" title="touchFile 与 diagnostics 聚合" note="touchFile 捕获异常，因此 LSP 故障通常不会回滚已经完成的文件修改。" -->

“best-effort”不是坏事：它保持文件工具可用。但调用者必须把“诊断为空”和“验证通过”分开。

## 9. OpenCode 的选择：延迟、完整性与韧性

### 选择一：按文件懒启动

不用在启动 OpenCode 时拉起所有语言服务，节省资源；代价是第一次触碰某类文件会承担 spawn 与 initialize 延迟。

### 选择二：同时支持 push 与 pull

兼容不同 server 和动态 capability；代价是缓存、时间/version 判定、去重和等待逻辑明显更复杂。

### 选择三：document 模式给编辑反馈，full 模式留给更广检查

局部修改优先低延迟；代价是相关文件诊断可能不完整。`WriteTool` 读取聚合缓存能补充一些项目错误，但不能替代全量构建。

### 选择四：错误降级而不是阻断落盘

语言服务只是增强层，崩溃时仍允许 Agent 工作；代价是成功结果必须携带“验证可能缺失”的认知。

## 10. 可以带走的方法

### 方法一：按 `(能力实现, 项目根)` 复用重服务

语言服务、索引器、编译 daemon 都适合这样缓存。验证问题：**并发首次请求是否会重复启动同一实例？失败后如何退避或恢复？**

### 方法二：等待异步结果时携带版本与时间边界

只等“任意一个结果”容易拿到旧缓存。验证问题：**如何证明反馈与本次输入版本相关？**

### 方法三：增强层失败时明确降低置信度

可以继续主流程，但结果语义要区分“无错误”和“未完成检查”。验证问题：**调用者能否知道验证器未运行或已超时？**

## 11. 费曼复述与练习

不看源码，用 90 秒回答：

1. `clients / spawning / broken` 三个集合分别解决什么问题？
2. `notify.open` 为什么第二次会发 `didChange`？
3. push 与 pull diagnostics 怎样汇合？
4. 为什么空 diagnostics 不能直接解释为代码正确？

练习梯子：

1. **定位题**：从 `EditTool` 的 `touchFile` 追到 `client.notify.open`。
2. **时序题**：画出“文件 version 1 → didChange → push/pull → report”的序列。
3. **故障题**：假设 typescript language server 初始化超时，说明文件状态和 Agent 可见结果。
4. **实现题**：写一个只支持单 client 的 diagnostics cache，键必须包含文件路径和版本。
5. **迁移题**：把同样的 lazy + inflight + broken 模式用于项目索引器。

## 12. 最后复盘：反馈有了，谁决定能不能行动？

LSP 的闭环是：

```text
文件落盘 -> 选择/启动语言服务 -> 同步文档版本 -> 等待诊断 -> 聚合限流 -> 回填 Agent
```

它让 Agent 像有了一双 IDE 的眼睛，但眼睛只负责看，不负责授权。文件修改前为什么能暂停等待用户？默认规则为什么有的 allow、有的 ask、有的 deny？“总是允许”又怎样影响后续请求？下一章进入真正的 runtime 闸门：**权限、审批与安全边界。**
