跳转到内容

LSP / 诊断 / 上下文增强

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度较难
预计阅读45 分钟
  • packages/opencode/src/lsp/lsp.ts
  • packages/opencode/src/lsp/client.ts
  • packages/opencode/src/lsp/diagnostic.ts
  • packages/opencode/src/tool/edit.ts
  • packages/opencode/src/tool/write.ts

源码基线:v1.18.16(提交 a3647eb025c7)。本章追踪的是“编辑一个 TypeScript 文件后”的典型源码路径,不是实际启动 language server 的运行记录;具体可用 server 取决于本机配置与依赖。

学完本章,你应该能:

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

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

本章的中心问题是:

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

  • LSP state 仍按 instance 保存 clients、servers、broken 与 spawning,但 server 配置和事件类型已经接入新的 core/schema 公共包。
  • getClients 对同一 root/server 的并发启动继续去重,并在失败后写入 broken;编辑工具因此不会无限重启坏掉的 language server。
  • touchFilediagnostics 仍是文件工具面对的稳定接口,最新 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配置/内置注册
clientLSPClient.InfoJSON-RPC 连接、文档版本、诊断缓存按 server + root 复用
serviceLSP.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。这是教学输入,不代表本章真的改过该文件。

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:

  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

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.tspackages/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.tspackages/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.tspackages/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.tspackages/opencode/src/lsp/client.tspackages/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.tspackages/opencode/src/tool/write.ts

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

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

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

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

Section titled “7. 语义上下文:同一 service 还能回答什么”

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

  • hovertextDocument/hover
  • definitiontextDocument/definition
  • referencestextDocument/references
  • implementationtextDocument/implementation
  • documentSymbol / 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/infometadata 可保留Diagnostic.report 不把它拼入错误 block
error 超过 20 条截断并显示剩余数量Agent 先看到前 20 条

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

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

Section titled “9. OpenCode 的选择:延迟、完整性与韧性”

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

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

选择三:document 模式给编辑反馈,full 模式留给更广检查

Section titled “选择三:document 模式给编辑反馈,full 模式留给更广检查”

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

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

Section titled “选择四:错误降级而不是阻断落盘”

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

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

Section titled “方法一:按 (能力实现, 项目根) 复用重服务”

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

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

Section titled “方法二:等待异步结果时携带版本与时间边界”

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

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

Section titled “方法三:增强层失败时明确降低置信度”

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

不看源码,用 90 秒回答:

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

练习梯子:

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

12. 最后复盘:反馈有了,谁决定能不能行动?

Section titled “12. 最后复盘:反馈有了,谁决定能不能行动?”

LSP 的闭环是:

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

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