LSP / 诊断 / 上下文增强
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
08-lsp-diagnostics - 章节摘要:沿一次 edit 后诊断,理解 LSP client 的懒启动与复用、didOpen/didChange、push/pull diagnostics 和 best-effort 边界。
- 教程版本:
v1.18.16 - 源码基线:
a3647eb025c7615159d417dcc49fc39fdaeba65b - 章节元数据:/versions/v1-18-16/data/chapters.json
- 源码映射:/versions/v1-18-16/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/lsp/lsp.tspackages/opencode/src/lsp/client.tspackages/opencode/src/lsp/diagnostic.tspackages/opencode/src/tool/edit.tspackages/opencode/src/tool/write.ts
源码基线:
v1.18.16(提交a3647eb025c7)。本章追踪的是“编辑一个 TypeScript 文件后”的典型源码路径,不是实际启动 language server 的运行记录;具体可用 server 取决于本机配置与依赖。
0. 本章学习目标
Section titled “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. 一句话讲明白
Section titled “1. 一句话讲明白”LSP 是 OpenCode 的“IDE 反馈层”:文件落盘后,它让匹配的 language server 重新看到该文档,等待一小段时间收集诊断,再把有限的 error 文本放回工具结果,让 Agent 有机会继续修。
本章的中心问题是:
文件写成功只证明 I/O 成功;OpenCode 如何快速知道这次修改是否引入了语言级错误,又如何避免 LSP 故障反过来阻断所有编辑?
v1.18.16 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- LSP state 仍按 instance 保存 clients、servers、broken 与 spawning,但 server 配置和事件类型已经接入新的 core/schema 公共包。
getClients对同一 root/server 的并发启动继续去重,并在失败后写入broken;编辑工具因此不会无限重启坏掉的 language server。touchFile与diagnostics仍是文件工具面对的稳定接口,最新edit/write不需要理解具体 server 的 JSON-RPC。
按文件复用或启动 LSP client
packages/opencode/src/lsp/lsp.ts:208-284
扩展名、root、broken 与 spawning 共同限制 client 创建。
208 const getClients = Effect.fnUntraced(function* (file: string) {Effect 异步工作流。209 const ctx = yield* InstanceState.context等待 Effect 结果。210 if (!containsPath(file, ctx)) return [] as LSPClient.Info[]处理语言服务诊断。211 const s = yield* InstanceState.get(state)等待 Effect 结果。212 const clients = yield* Effect.promise(async () => {Effect 异步工作流。213 const extension = path.parse(file).ext || file开始解析命令参数。214 const result: LSPClient.Info[] = []处理语言服务诊断。215 let updated = 0216217 async function schedule(server: LSPServer.Info, root: string, key: string) {处理语言服务诊断。218 const handle = await server219 .spawn(root, ctx, flags)220 .then((value) => {221 if (!value) s.broken.add(key)按条件进入分支。222 return value返回给上一层。223 })224 .catch(() => {225 s.broken.add(key)226 return undefined返回给上一层。227 })228229 if (!handle) return undefined按条件进入分支。230 const client = await LSPClient.create({处理语言服务诊断。231 serverID: server.id,232 server: handle,233 root,234 directory: ctx.directory,235 instance: ctx,236 }).catch(async () => {237 s.broken.add(key)238 await Process.stop(handle.process)239 return undefined返回给上一层。240 })241242 if (!client) return undefined按条件进入分支。243244 const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)245 if (existing) {按条件进入分支。246 await Process.stop(handle.process)247 return existing返回给上一层。248 }249250 s.clients.push(client)251 return client返回给上一层。252 }253254 for (const server of Object.values(s.servers)) {遍历集合。255 if (server.extensions.length && !server.extensions.includes(extension)) continue按条件进入分支。256257 const root = await server.root(file, ctx)258 if (!root) continue按条件进入分支。259 if (s.broken.has(root + server.id)) continue按条件进入分支。260261 const match = s.clients.find((x) => x.root === root && x.serverID === server.id)262 if (match) {按条件进入分支。263 result.push(match)264 continue265 }266267 const inflight = s.spawning.get(root + server.id)268 if (inflight) {按条件进入分支。269 const client = await inflight270 if (!client) continue按条件进入分支。271 result.push(client)272 continue273 }274275 const task = schedule(server, root, root + server.id)276 s.spawning.set(root + server.id, task)277278 task.finally(() => {279 if (s.spawning.get(root + server.id) === task) {按条件进入分支。280 s.spawning.delete(root + server.id)281 }282 })283284 const client = await task
文件触达与诊断汇总
packages/opencode/src/lsp/lsp.ts:344-374
工具只依赖这两个稳定服务方法。
344 const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {处理语言服务诊断。345 yield* Effect.logInfo("touching file", { file: input })Effect 异步工作流。346 const clients = yield* getClients(input)等待 Effect 结果。347 yield* Effect.promise(() =>Effect 异步工作流。348 Promise.all(并行等待多个任务。349 clients.map(async (client) => {350 const after = Date.now()351 const version = await client.notify.open({ path: input })352 if (!diagnostics) return处理语言服务诊断。353 return client.waitForDiagnostics({处理语言服务诊断。354 path: input,355 version,356 mode: diagnostics,处理语言服务诊断。357 after,358 })359 }),360 ).catch(() => {}),361 )362 })363364 const diagnostics = Effect.fn("LSP.diagnostics")(function* () {处理语言服务诊断。365 const results: Record<string, LSPClient.Diagnostic[]> = {}处理语言服务诊断。366 const all = yield* runAll(async (client) => client.diagnostics)处理语言服务诊断。367 for (const result of all) {遍历集合。368 for (const [p, diags] of result.entries()) {遍历集合。369 const arr = results[p] || []370 arr.push(...diags)371 results[p] = arr372 }373 }374 return results返回给上一层。
2. 先看全图:LSP 在哪里,不在哪里
Section titled “2. 先看全图:LSP 在哪里,不在哪里”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。
3. 最小机制:把编辑后的文件重新交给分析器
Section titled “3. 最小机制:把编辑后的文件重新交给分析器”忽略 JSON-RPC 和多语言细节,最小闭环是:
1async function diagnoseAfterEdit(file) {定义一段可复用逻辑。2 const clients = await clientsFor(file)3 for (const client of clients) {遍历集合。4 const version = await client.openOrChange(file)5 await client.waitForDiagnostics(file, version)处理语言服务诊断。6 }7 return onlyErrorsAndLimit(merge(clients.map(c => c.diagnostics)))处理语言服务诊断。8}
为什么不能只读一次 client.diagnostics?因为文件刚改完时,缓存可能仍是旧版本。touchFile 先发文档通知,再按 mode 等待 push 或 pull 结果,正是在处理这个时间差。
Java 开发者可以把它类比为一个长期运行的 LanguageIntelligenceService,但不要类比成每次调用 javac:LSP client 与 server 会跨多次编辑复用,并通过 JSON-RPC 增量保持文档状态。
4. 三层对象:server 配置、client 连接、service 路由
Section titled “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。
5. 一条具体源码旅程:edit 后诊断 src/config.ts
Section titled “5. 一条具体源码旅程:edit 后诊断 src/config.ts”假设 EditTool 已经成功修改 /workspace/project/src/config.ts。这是教学输入,不代表本章真的改过该文件。
5.1 文件先落盘,LSP 后介入
Section titled “5.1 文件先落盘,LSP 后介入”EditTool 在写入、格式化、发布事件并更新 metadata 之后才调用:
1yield* lsp.touchFile(filePath, "document")等待 Effect 结果。2const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。3const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。
路径:packages/opencode/src/tool/edit.ts
这建立了明确的因果顺序:language server 读取的是最终落盘且可能已格式化的文件,而不是模型最初提交的 newString。
5.2 getClients 先判断“谁能理解这个文件”
Section titled “5.2 getClients 先判断“谁能理解这个文件””getClients(file) 首先拒绝 instance 外文件,然后取扩展名,遍历 server:
- server 有 extensions 且不包含
.ts:跳过。 server.root(file, ctx)找不到 root:跳过。(root + server.id)已在broken:跳过。- 已有相同 root/server client:复用。
- 正在 spawn:等待同一个 in-flight Promise。
- 否则 schedule 一次 spawn + initialize。
路径:packages/opencode/src/lsp/lsp.ts。
spawning 解决并发触发下的重复启动;broken 让当前 instance 不再反复尝试同一 root/server 组合。后者提高稳定性,但也意味着环境在运行中被修好后,不一定会自动重试。
5.3 schedule 把进程变成 JSON-RPC client
Section titled “5.3 schedule 把进程变成 JSON-RPC client”server spawn 失败或返回空值会标记 broken。进程启动后,LSPClient.create 若初始化失败,也会停止进程并标记 broken。见 packages/opencode/src/lsp/lsp.ts。
client 用 server stdout/stdin 建立 JSON-RPC message connection:
1const connection = createMessageConnection(2 new StreamMessageReader(input.server.process.stdout as any),3 new StreamMessageWriter(input.server.process.stdin as any),4)
路径:packages/opencode/src/lsp/client.ts
initialize 有 45 秒 timeout,并声明文档同步、动态注册诊断和 related document 等 capability。失败被包装成 InitializeError。见 packages/opencode/src/lsp/client.ts、packages/opencode/src/lsp/client.ts。
5.4 touchFile 选择 didOpen 还是 didChange
Section titled “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。
第二次 touch 不会预先清空 diagnostics,因为 clangd 等 server 对无内容变化的触碰可能不重新发布;贸然清空会把仍有效的错误丢掉。这个取舍由注释和控制流直接说明,见 packages/opencode/src/lsp/client.ts。
5.5 document 模式同时兼容 push 与 pull
Section titled “5.5 document 模式同时兼容 push 与 pull”touchFile(file, "document") 记录 after 时间和文档 version,再调用 waitForDiagnostics。document 等待上限为 5 秒,full 为 10 秒,单次 pull request 为 3 秒。见 packages/opencode/src/lsp/client.ts、packages/opencode/src/lsp/lsp.ts。
push 路径:server 发 textDocument/publishDiagnostics,client 记录时间/version并更新 cache。见 packages/opencode/src/lsp/client.ts。
pull 路径:client 主动请求 textDocument/diagnostic;若 server 动态注册多个 identifier,会并行发出请求,一旦已有一批产生当前文件诊断即可解除等待,较慢结果继续在后台合并。见 packages/opencode/src/lsp/client.ts、packages/opencode/src/lsp/client.ts。
waitForDocumentDiagnostics 在 pull 未匹配时,会与 fresh push、capability registration 变化竞争,直到结果或超时。见 packages/opencode/src/lsp/client.ts。
5.6 聚合后只把有限 error 交给模型
Section titled “5.6 聚合后只把有限 error 交给模型”每个 client 内部先合并、去重 push 与 pull diagnostics。service 再把所有已启动 client 的 map 按文件合并。见 packages/opencode/src/lsp/client.ts、packages/opencode/src/lsp/client.ts、packages/opencode/src/lsp/lsp.ts。
Diagnostic.report 只保留 severity = 1 的 error,每个文件最多输出 20 条,并把 0-based line/character 转成 1-based 文本位置。见 packages/opencode/src/lsp/diagnostic.ts。
因此:
- warning/info/hint 仍可能存在于 metadata 的 diagnostics map;
- 但
EditTool拼给模型的文本 block 只包含 error; - “没有 block”既可能是没有 error,也可能是没有可用 client、等待未得到新结果或 LSP 已被禁用。
6. WriteTool 为什么会看到“其他文件”的错误
Section titled “6. WriteTool 为什么会看到“其他文件”的错误”WriteTool 同样调用 touchFile(filepath, "document"),然后遍历整个聚合 diagnostics map:当前文件优先报告,其他有 error 的文件最多报告五个。见 packages/opencode/src/tool/write.ts、packages/opencode/src/tool/write.ts。
这对创建公共类型或配置文件很有用:一个文件的变化可能让引用方报错。但它不是完整项目诊断保证:
documentmode 重点等待当前文件,不等同于完整 workspace 检查;- 只有已经启动的 clients 会被
runAll聚合; - 文本输出还限制了其他文件数量。
若需要构建级确信,仍应运行测试、编译或专门的检查命令。
7. 语义上下文:同一 service 还能回答什么
Section titled “7. 语义上下文:同一 service 还能回答什么”LSP.Service 把位置查询路由给适用 clients:
hover→textDocument/hoverdefinition→textDocument/definitionreferences→textDocument/referencesimplementation→textDocument/implementationdocumentSymbol / workspaceSymbol- incoming / outgoing call hierarchy
路径:packages/opencode/src/lsp/lsp.ts。
多数查询失败会被降级为 null 或空数组,再把多个 client 结果拍平。这体现同一设计:LSP 是增强信息,不应轻易让整个 Agent 工作流崩溃。
证据边界:本章列出的 sourceFiles 证明 service 能力与 JSON-RPC 请求;模型怎样把这些能力作为具体 tool 使用,不在本章这条自动 diagnostics 旅程内。
8. 失败路径:best-effort 到底意味着什么
Section titled “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 条 |
“best-effort”不是坏事:它保持文件工具可用。但调用者必须把“诊断为空”和“验证通过”分开。
9. OpenCode 的选择:延迟、完整性与韧性
Section titled “9. OpenCode 的选择:延迟、完整性与韧性”选择一:按文件懒启动
Section titled “选择一:按文件懒启动”不用在启动 OpenCode 时拉起所有语言服务,节省资源;代价是第一次触碰某类文件会承担 spawn 与 initialize 延迟。
选择二:同时支持 push 与 pull
Section titled “选择二:同时支持 push 与 pull”兼容不同 server 和动态 capability;代价是缓存、时间/version 判定、去重和等待逻辑明显更复杂。
选择三:document 模式给编辑反馈,full 模式留给更广检查
Section titled “选择三:document 模式给编辑反馈,full 模式留给更广检查”局部修改优先低延迟;代价是相关文件诊断可能不完整。WriteTool 读取聚合缓存能补充一些项目错误,但不能替代全量构建。
选择四:错误降级而不是阻断落盘
Section titled “选择四:错误降级而不是阻断落盘”语言服务只是增强层,崩溃时仍允许 Agent 工作;代价是成功结果必须携带“验证可能缺失”的认知。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:按 (能力实现, 项目根) 复用重服务
Section titled “方法一:按 (能力实现, 项目根) 复用重服务”语言服务、索引器、编译 daemon 都适合这样缓存。验证问题:并发首次请求是否会重复启动同一实例?失败后如何退避或恢复?
方法二:等待异步结果时携带版本与时间边界
Section titled “方法二:等待异步结果时携带版本与时间边界”只等“任意一个结果”容易拿到旧缓存。验证问题:如何证明反馈与本次输入版本相关?
方法三:增强层失败时明确降低置信度
Section titled “方法三:增强层失败时明确降低置信度”可以继续主流程,但结果语义要区分“无错误”和“未完成检查”。验证问题:调用者能否知道验证器未运行或已超时?
11. 费曼复述与练习
Section titled “11. 费曼复述与练习”不看源码,用 90 秒回答:
clients / spawning / broken三个集合分别解决什么问题?notify.open为什么第二次会发didChange?- push 与 pull diagnostics 怎样汇合?
- 为什么空 diagnostics 不能直接解释为代码正确?
练习梯子:
- 定位题:从
EditTool的touchFile追到client.notify.open。 - 时序题:画出“文件 version 1 → didChange → push/pull → report”的序列。
- 故障题:假设 typescript language server 初始化超时,说明文件状态和 Agent 可见结果。
- 实现题:写一个只支持单 client 的 diagnostics cache,键必须包含文件路径和版本。
- 迁移题:把同样的 lazy + inflight + broken 模式用于项目索引器。
12. 最后复盘:反馈有了,谁决定能不能行动?
Section titled “12. 最后复盘:反馈有了,谁决定能不能行动?”LSP 的闭环是:
文件落盘 -> 选择/启动语言服务 -> 同步文档版本 -> 等待诊断 -> 聚合限流 -> 回填 Agent它让 Agent 像有了一双 IDE 的眼睛,但眼睛只负责看,不负责授权。文件修改前为什么能暂停等待用户?默认规则为什么有的 allow、有的 ask、有的 deny?“总是允许”又怎样影响后续请求?下一章进入真正的 runtime 闸门:权限、审批与安全边界。