LSP / 诊断 / 上下文增强
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
08-lsp-diagnostics - 章节摘要:沿一次 edit 后诊断,理解 LSP client 的懒启动与复用、didOpen/didChange、push/pull diagnostics 和 best-effort 边界。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/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
源码基线:
eec0843ce422。本章追踪的是“编辑一个 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 故障反过来阻断所有编辑?
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:123-138。
packages/opencode/src/lsp/lsp.ts
packages/opencode/src/lsp/lsp.ts:123-138
123export interface Interface {定义数据结构约束。124 readonly init: () => Effect.Effect<void>Effect 异步工作流。125 readonly status: () => Effect.Effect<Status[]>Effect 异步工作流。126 readonly hasClients: (file: string) => Effect.Effect<boolean>Effect 异步工作流。127 readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect<void>处理语言服务诊断。128 readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>处理语言服务诊断。129 readonly hover: (input: LocInput) => Effect.Effect<any>Effect 异步工作流。130 readonly definition: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。131 readonly references: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。132 readonly implementation: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。133 readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>Effect 异步工作流。134 readonly workspaceSymbol: (query: string) => Effect.Effect<Symbol[]>Effect 异步工作流。135 readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。136 readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。137 readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>Effect 异步工作流。138}
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:148-199。
packages/opencode/src/lsp/lsp.ts
packages/opencode/src/lsp/lsp.ts:148-199
148 const state = yield* InstanceState.make<State>(等待 Effect 结果。149 Effect.fn("LSP.state")(function* (ctx) {处理语言服务诊断。150 const cfg = yield* config.get()读取运行配置。151152 const servers: Record<string, LSPServer.Info> = {}处理语言服务诊断。153154 if (!cfg.lsp) {按条件进入分支。155 log.info("all LSPs are disabled")处理语言服务诊断。156 } else {157 for (const server of Object.values(LSPServer)) {处理语言服务诊断。158 servers[server.id] = server159 }160161 filterExperimentalServers(servers, flags)162163 if (cfg.lsp !== true) {按条件进入分支。164 for (const [name, item] of Object.entries(cfg.lsp)) {遍历集合。165 const existing = servers[name]166 if (item.disabled) {按条件进入分支。167 log.info(`LSP server ${name} is disabled`)处理语言服务诊断。168 delete servers[name]169 continue170 }171 servers[name] = {172 ...existing,173 id: name,174 root: existing?.root ?? (async (_file, ctx) => ctx.directory),175 extensions: item.extensions ?? existing?.extensions ?? [],176 spawn: async (root) => ({177 process: lspspawn(item.command[0], item.command.slice(1), {处理命令执行。178 cwd: root,179 env: { ...process.env, ...item.env },180 }),181 initialization: item.initialization,182 }),183 }184 }185 }186187 log.info("enabled LSP servers", {处理语言服务诊断。188 serverIds: Object.values(servers)189 .map((server) => server.id)190 .join(", "),191 })192 }193194 const s: State = {195 clients: [],196 servers,197 broken: new Set(),198 spawning: new Map(),199 }
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:151-197
packages/opencode/src/tool/edit.ts
packages/opencode/src/tool/edit.ts:151-197
151 yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))准备修改文件内容。152 if (yield* format.file(filePath)) {等待 Effect 结果。153 contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)等待 Effect 结果。154 }155 yield* bus.publish(File.Event.Edited, { file: filePath })广播状态变化。156 yield* bus.publish(FileWatcher.Event.Updated, {广播状态变化。157 file: filePath,158 event: "change",159 })160 diff = trimDiff(161 createTwoFilesPatch(准备修改文件内容。162 filePath,163 filePath,164 normalizeLineEndings(contentOld),165 normalizeLineEndings(contentNew),166 ),167 )168 }).pipe(Effect.orDie),Effect 异步工作流。169 )170171 let additions = 0172 let deletions = 0173 for (const change of diffLines(contentOld, contentNew)) {遍历集合。174 if (change.added) additions += change.count || 0按条件进入分支。175 if (change.removed) deletions += change.count || 0按条件进入分支。176 }177 const filediff: Snapshot.FileDiff = {178 file: filePath,179 patch: diff,准备修改文件内容。180 additions,181 deletions,182 }183184 yield* ctx.metadata({等待 Effect 结果。185 metadata: {186 diff,187 filediff,188 diagnostics: {},处理语言服务诊断。189 },190 })191192 let output = "Edit applied successfully."193 yield* lsp.touchFile(filePath, "document")等待 Effect 结果。194 const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。195 const normalizedFilePath = AppFileSystem.normalizePath(filePath)读写本地文件。196 const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])处理语言服务诊断。197 if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。
这建立了明确的因果顺序: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:211-299。
packages/opencode/src/lsp/lsp.ts
packages/opencode/src/lsp/lsp.ts:211-299
211 const getClients = Effect.fnUntraced(function* (file: string) {Effect 异步工作流。212 const ctx = yield* InstanceState.context等待 Effect 结果。213 if (!containsPath(file, ctx)) return [] as LSPClient.Info[]处理语言服务诊断。214 const s = yield* InstanceState.get(state)等待 Effect 结果。215 return yield* Effect.promise(async () => {Effect 异步工作流。216 const extension = path.parse(file).ext || file开始解析命令参数。217 const result: LSPClient.Info[] = []处理语言服务诊断。218219 async function schedule(server: LSPServer.Info, root: string, key: string) {处理语言服务诊断。220 const handle = await server221 .spawn(root, ctx, flags)222 .then((value) => {223 if (!value) s.broken.add(key)按条件进入分支。224 return value返回给上一层。225 })226 .catch((err) => {227 s.broken.add(key)228 log.error(`Failed to spawn LSP server ${server.id}`, { error: err })处理语言服务诊断。229 return undefined返回给上一层。230 })231232 if (!handle) return undefined按条件进入分支。233 log.info("spawned lsp server", { serverID: server.id, root })234235 const client = await LSPClient.create({处理语言服务诊断。236 serverID: server.id,237 server: handle,238 root,239 directory: ctx.directory,240 instance: ctx,241 }).catch(async (err) => {242 s.broken.add(key)243 await Process.stop(handle.process)244 log.error(`Failed to initialize LSP client ${server.id}`, { error: err })处理语言服务诊断。245 return undefined返回给上一层。246 })247248 if (!client) return undefined按条件进入分支。249250 const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)251 if (existing) {按条件进入分支。252 await Process.stop(handle.process)253 return existing返回给上一层。254 }255256 s.clients.push(client)257 return client返回给上一层。258 }259260 for (const server of Object.values(s.servers)) {遍历集合。261 if (server.extensions.length && !server.extensions.includes(extension)) continue按条件进入分支。262263 const root = await server.root(file, ctx)264 if (!root) continue按条件进入分支。265 if (s.broken.has(root + server.id)) continue按条件进入分支。266267 const match = s.clients.find((x) => x.root === root && x.serverID === server.id)268 if (match) {按条件进入分支。269 result.push(match)270 continue271 }272273 const inflight = s.spawning.get(root + server.id)274 if (inflight) {按条件进入分支。275 const client = await inflight276 if (!client) continue按条件进入分支。277 result.push(client)278 continue279 }280281 const task = schedule(server, root, root + server.id)282 s.spawning.set(root + server.id, task)283284 task.finally(() => {285 if (s.spawning.get(root + server.id) === task) {按条件进入分支。286 s.spawning.delete(root + server.id)287 }288 })289290 const client = await task291 if (!client) continue按条件进入分支。292293 result.push(client)294 await Bus.publish(ctx, Event.Updated, {})广播状态变化。295 }296297 return result返回给上一层。298 })299 })
getClients:按文件懒启动并复用 LSP client
packages/opencode/src/lsp/lsp.ts:211-299
重点观察 clients、spawning、broken 三个状态如何避免重复启动和重复失败。
211 const getClients = Effect.fnUntraced(function* (file: string) {Effect 异步工作流。212 const ctx = yield* InstanceState.context等待 Effect 结果。213 if (!containsPath(file, ctx)) return [] as LSPClient.Info[]处理语言服务诊断。214 const s = yield* InstanceState.get(state)等待 Effect 结果。215 return yield* Effect.promise(async () => {Effect 异步工作流。216 const extension = path.parse(file).ext || file开始解析命令参数。217 const result: LSPClient.Info[] = []处理语言服务诊断。218219 async function schedule(server: LSPServer.Info, root: string, key: string) {处理语言服务诊断。220 const handle = await server221 .spawn(root, ctx, flags)222 .then((value) => {223 if (!value) s.broken.add(key)按条件进入分支。224 return value返回给上一层。225 })226 .catch((err) => {227 s.broken.add(key)228 log.error(`Failed to spawn LSP server ${server.id}`, { error: err })处理语言服务诊断。229 return undefined返回给上一层。230 })231232 if (!handle) return undefined按条件进入分支。233 log.info("spawned lsp server", { serverID: server.id, root })234235 const client = await LSPClient.create({处理语言服务诊断。236 serverID: server.id,237 server: handle,238 root,239 directory: ctx.directory,240 instance: ctx,241 }).catch(async (err) => {242 s.broken.add(key)243 await Process.stop(handle.process)244 log.error(`Failed to initialize LSP client ${server.id}`, { error: err })处理语言服务诊断。245 return undefined返回给上一层。246 })247248 if (!client) return undefined按条件进入分支。249250 const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)251 if (existing) {按条件进入分支。252 await Process.stop(handle.process)253 return existing返回给上一层。254 }255256 s.clients.push(client)257 return client返回给上一层。258 }259260 for (const server of Object.values(s.servers)) {遍历集合。261 if (server.extensions.length && !server.extensions.includes(extension)) continue按条件进入分支。262263 const root = await server.root(file, ctx)264 if (!root) continue按条件进入分支。265 if (s.broken.has(root + server.id)) continue按条件进入分支。266267 const match = s.clients.find((x) => x.root === root && x.serverID === server.id)268 if (match) {按条件进入分支。269 result.push(match)270 continue271 }272273 const inflight = s.spawning.get(root + server.id)274 if (inflight) {按条件进入分支。275 const client = await inflight276 if (!client) continue按条件进入分支。277 result.push(client)278 continue279 }280281 const task = schedule(server, root, root + server.id)282 s.spawning.set(root + server.id, task)283284 task.finally(() => {285 if (s.spawning.get(root + server.id) === task) {按条件进入分支。286 s.spawning.delete(root + server.id)287 }288 })289290 const client = await task291 if (!client) continue按条件进入分支。292293 result.push(client)294 await Bus.publish(ctx, Event.Updated, {})广播状态变化。295 }296297 return result返回给上一层。298 })299 })
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:219-257。
packages/opencode/src/lsp/lsp.ts
packages/opencode/src/lsp/lsp.ts:219-257
219 async function schedule(server: LSPServer.Info, root: string, key: string) {处理语言服务诊断。220 const handle = await server221 .spawn(root, ctx, flags)222 .then((value) => {223 if (!value) s.broken.add(key)按条件进入分支。224 return value返回给上一层。225 })226 .catch((err) => {227 s.broken.add(key)228 log.error(`Failed to spawn LSP server ${server.id}`, { error: err })处理语言服务诊断。229 return undefined返回给上一层。230 })231232 if (!handle) return undefined按条件进入分支。233 log.info("spawned lsp server", { serverID: server.id, root })234235 const client = await LSPClient.create({处理语言服务诊断。236 serverID: server.id,237 server: handle,238 root,239 directory: ctx.directory,240 instance: ctx,241 }).catch(async (err) => {242 s.broken.add(key)243 await Process.stop(handle.process)244 log.error(`Failed to initialize LSP client ${server.id}`, { error: err })处理语言服务诊断。245 return undefined返回给上一层。246 })247248 if (!client) return undefined按条件进入分支。249250 const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)251 if (existing) {按条件进入分支。252 await Process.stop(handle.process)253 return existing返回给上一层。254 }255256 s.clients.push(client)257 return client返回给上一层。
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:141-155
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:141-155
141export async function create(input: {对外暴露模块成员。142 serverID: string143 server: LSPServer.Handle处理语言服务诊断。144 root: string145 directory: string146 instance: InstanceContext147}) {148 const logger = log.clone().tag("serverID", input.serverID)149 logger.info("starting client")150 const instance = input.instance151152 const connection = createMessageConnection(153 new StreamMessageReader(input.server.process.stdout as any),154 new StreamMessageWriter(input.server.process.stdin as any),155 )
initialize 有 45 秒 timeout,并声明文档同步、动态注册诊断和 related document 等 capability。失败被包装成 InitializeError。见 packages/opencode/src/lsp/client.ts:18-23、packages/opencode/src/lsp/client.ts:246-305。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:18-23
18const DIAGNOSTICS_DEBOUNCE_MS = 15019const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_00020const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_00021const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_0002223const INITIALIZE_TIMEOUT_MS = 45_000
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:246-305
246 // --- Initialize handshake ---247248 logger.info("sending initialize")249 const initialized = await withTimeout(250 connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {251 rootUri: pathToFileURL(input.root).href,252 processId: input.server.process.pid,253 workspaceFolders: [254 {255 name: "workspace",256 uri: pathToFileURL(input.root).href,257 },258 ],259 initializationOptions: {260 ...input.server.initialization,261 },262 capabilities: {263 window: {264 workDoneProgress: true,265 },266 workspace: {267 configuration: true,268 didChangeWatchedFiles: {269 dynamicRegistration: true,270 },271 diagnostics: {处理语言服务诊断。272 refreshSupport: false,273 },274 },275 textDocument: {276 synchronization: {277 didOpen: true,278 didChange: true,279 },280 diagnostic: {处理语言服务诊断。281 dynamicRegistration: true,282 relatedDocumentSupport: true,283 },284 publishDiagnostics: {处理语言服务诊断。285 versionSupport: false,286 },287 },288 },289 }),290 INITIALIZE_TIMEOUT_MS,291 ).catch((err) => {292 logger.error("initialize error", { error: err })293 throw new InitializeError({ serverID: input.serverID, cause: err })失败时抛出错误。294 })295296 const syncKind = getSyncKind(initialized.capabilities)297 const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider)选择模型或 provider。298299 await connection.sendNotification("initialized", {})300301 if (input.server.initialization) {按条件进入分支。302 await connection.sendNotification("workspace/didChangeConfiguration", {303 settings: input.server.initialization,304 })305 }
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:594-669。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:594-669
594 notify: {595 async open(request: { path: string }) {596 request.path = Filesystem.normalizePath(597 path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),598 )599 const text = await Filesystem.readText(request.path)600 const extension = path.extname(request.path)601 const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"602603 const document = files[request.path]604 if (document !== undefined) {按条件进入分支。605 // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only606 // re-emit diagnostics when the content actually changes, so clearing607 // here would lose errors for no-op touchFile calls. Let the server's608 // next push/pull overwrite naturally.609 logger.info("workspace/didChangeWatchedFiles", request)610 await connection.sendNotification("workspace/didChangeWatchedFiles", {611 changes: [612 {613 uri: pathToFileURL(request.path).href,614 type: FILE_CHANGE_CHANGED,615 },616 ],617 })618619 const next = document.version + 1620 files[request.path] = { version: next, text }621 logger.info("textDocument/didChange", {622 path: request.path,623 version: next,624 })625 await connection.sendNotification("textDocument/didChange", {626 textDocument: {627 uri: pathToFileURL(request.path).href,628 version: next,629 },630 contentChanges:631 syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL632 ? [633 {634 range: {635 start: { line: 0, character: 0 },636 end: endPosition(document.text),637 },638 text,639 },640 ]641 : [{ text }],642 })643 return next返回给上一层。644 }645646 logger.info("workspace/didChangeWatchedFiles", request)647 await connection.sendNotification("workspace/didChangeWatchedFiles", {648 changes: [649 {650 uri: pathToFileURL(request.path).href,651 type: FILE_CHANGE_CREATED,652 },653 ],654 })655656 logger.info("textDocument/didOpen", request)657 pushDiagnostics.delete(request.path)处理语言服务诊断。658 pullDiagnostics.delete(request.path)处理语言服务诊断。659 await connection.sendNotification("textDocument/didOpen", {660 textDocument: {661 uri: pathToFileURL(request.path).href,662 languageId,663 version: 0,664 text,665 },666 })667 files[request.path] = { version: 0, text }668 return 0返回给上一层。669 },
第二次 touch 不会预先清空 diagnostics,因为 clangd 等 server 对无内容变化的触碰可能不重新发布;贸然清空会把仍有效的错误丢掉。这个取舍由注释和控制流直接说明,见 packages/opencode/src/lsp/client.ts:603-608。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:603-608
603 const document = files[request.path]604 if (document !== undefined) {按条件进入分支。605 // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only606 // re-emit diagnostics when the content actually changes, so clearing607 // here would lose errors for no-op touchFile calls. Let the server's608 // next push/pull overwrite naturally.
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:18-23、packages/opencode/src/lsp/lsp.ts:346-366。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:18-23
18const DIAGNOSTICS_DEBOUNCE_MS = 15019const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_00020const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_00021const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_0002223const INITIALIZE_TIMEOUT_MS = 45_000
packages/opencode/src/lsp/lsp.ts
packages/opencode/src/lsp/lsp.ts:346-366
346 const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {处理语言服务诊断。347 log.info("touching file", { file: input })348 const clients = yield* getClients(input)等待 Effect 结果。349 yield* Effect.promise(() =>Effect 异步工作流。350 Promise.all(并行等待多个任务。351 clients.map(async (client) => {352 const after = Date.now()353 const version = await client.notify.open({ path: input })354 if (!diagnostics) return处理语言服务诊断。355 return client.waitForDiagnostics({处理语言服务诊断。356 path: input,357 version,358 mode: diagnostics,处理语言服务诊断。359 after,360 })361 }),362 ).catch((err) => {363 log.error("failed to touch file", { err, file: input })364 }),365 )366 })
push 路径:server 发 textDocument/publishDiagnostics,client 记录时间/version并更新 cache。见 packages/opencode/src/lsp/client.ts:191-208。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:191-208
191 connection.onNotification("textDocument/publishDiagnostics", (params) => {处理语言服务诊断。192 const filePath = getFilePath(params.uri)193 if (!filePath) return按条件进入分支。194 logger.info("textDocument/publishDiagnostics", {处理语言服务诊断。195 path: filePath,196 count: params.diagnostics.length,处理语言服务诊断。197 version: params.version,198 })199 published.set(filePath, {200 at: Date.now(),201 version: typeof params.version === "number" ? params.version : undefined,202 })203 if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) {处理语言服务诊断。204 pushDiagnostics.set(filePath, params.diagnostics)处理语言服务诊断。205 return返回给上一层。206 }207 updatePushDiagnostics(filePath, params.diagnostics)处理语言服务诊断。208 })
pull 路径:client 主动请求 textDocument/diagnostic;若 server 动态注册多个 identifier,会并行发出请求,一旦已有一批产生当前文件诊断即可解除等待,较慢结果继续在后台合并。见 packages/opencode/src/lsp/client.ts:332-366、packages/opencode/src/lsp/client.ts:451-466。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:332-366
332 async function requestDiagnosticReport(filePath: string, identifier?: string): Promise<DiagnosticRequestResult> {定义一段可复用逻辑。333 const report = await withTimeout(334 connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {处理语言服务诊断。335 ...(identifier ? { identifier } : {}),336 textDocument: {337 uri: pathToFileURL(filePath).href,338 },339 }),340 DIAGNOSTICS_REQUEST_TIMEOUT_MS,341 ).catch(() => null)342 if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() }按条件进入分支。343344 const byFile = new Map<string, Diagnostic[]>()345 const push = (target: string, items: Diagnostic[]) => {346 const existing = byFile.get(target) ?? []347 byFile.set(target, existing.concat(items))348 }349350 let handled = false351 let matched = false352 if (Array.isArray(report.items)) {按条件进入分支。353 push(filePath, report.items)354 handled = true355 matched = true356 }357 for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {遍历集合。358 const relatedPath = getFilePath(uri)359 if (!relatedPath || !Array.isArray(related.items)) continue按条件进入分支。360 push(relatedPath, related.items)361 handled = true362 matched = matched || relatedPath === filePath363 }364365 return { handled, matched, byFile }返回给上一层。366 }
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:451-466
451 // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one452 // batch already produced diagnostics for the current file. Let slower pulls keep453 // merging in the background; do not sequence identifier-by-identifier, and do454 // not add a post-match settle/debounce delay. See PR #23771.455 async function requestDocumentDiagnostics(filePath: string) {处理语言服务诊断。456 const state = documentPullState()457 if (!state.supported) return { handled: false, matched: false }按条件进入分支。458 return requestDiagnostics(处理语言服务诊断。459 filePath,460 [461 requestDiagnosticReport(filePath),462 ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),463 ],464 (results) => hasCurrentFileDiagnostics(filePath, results),处理语言服务诊断。465 )466 }
waitForDocumentDiagnostics 在 pull 未匹配时,会与 fresh push、capability registration 变化竞争,直到结果或超时。见 packages/opencode/src/lsp/client.ts:503-560。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:503-560
503 function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) {定义一段可复用逻辑。504 if (request.timeout <= 0) return Promise.resolve(false)按条件进入分支。505 return new Promise<boolean>((resolve) => {返回给上一层。506 let finished = false507 let debounceTimer: ReturnType<typeof setTimeout> | undefined508 let timeoutTimer: ReturnType<typeof setTimeout> | undefined509 let unsub: (() => void) | undefined510 const finish = (result: boolean) => {511 if (finished) return按条件进入分支。512 finished = true513 if (debounceTimer) clearTimeout(debounceTimer)按条件进入分支。514 if (timeoutTimer) clearTimeout(timeoutTimer)按条件进入分支。515 unsub?.()516 resolve(result)517 }518 const schedule = () => {519 const hit = published.get(request.path)520 if (!hit) return按条件进入分支。521 if (typeof hit.version === "number" && hit.version !== request.version) return按条件进入分支。522 if (hit.at < request.after && hit.version !== request.version) return按条件进入分支。523 if (debounceTimer) clearTimeout(debounceTimer)按条件进入分支。524 debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)))525 }526527 timeoutTimer = setTimeout(() => finish(false), request.timeout)528 unsub = busRuntime.runSync((svc) =>529 svc530 .subscribeCallback(Event.Diagnostics, (event) => {处理语言服务诊断。531 if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return按条件进入分支。532 schedule()533 })534 .pipe(Effect.provideService(InstanceRef, instance)),Effect 异步工作流。535 )536 schedule()537 })538 }539540 async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) {处理语言服务诊断。541 const startedAt = request.after ?? Date.now()542 const pushWait = waitForFreshPush({543 path: request.path,544 version: request.version,545 after: startedAt,546 timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,547 })548549 while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {550 const result = await requestDocumentDiagnostics(request.path)处理语言服务诊断。551 if (result.matched) return按条件进入分支。552 const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt)553 if (remaining <= 0) return按条件进入分支。554 const next = await Promise.race([555 pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),556 waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))),557 ])558 if (next !== "registration") return按条件进入分支。559 }560 }
5.6 聚合后只把有限 error 交给模型
Section titled “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。
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:167-184
167 const pushDiagnostics = new Map<string, Diagnostic[]>()处理语言服务诊断。168 const pullDiagnostics = new Map<string, Diagnostic[]>()处理语言服务诊断。169 const published = new Map<string, { at: number; version?: number }>()170 const diagnosticRegistrations = new Map<string, CapabilityRegistration>()处理语言服务诊断。171 const registrationListeners = new Set<() => void>()172 const mergedDiagnostics = (filePath: string) =>处理语言服务诊断。173 dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])处理语言服务诊断。174 const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {处理语言服务诊断。175 pushDiagnostics.set(filePath, next)处理语言服务诊断。176 void busRuntime.runPromise((svc) =>177 svc178 .publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })广播状态变化。179 .pipe(Effect.provideService(InstanceRef, instance)),Effect 异步工作流。180 )181 }182 const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {处理语言服务诊断。183 pullDiagnostics.set(filePath, next)处理语言服务诊断。184 }
packages/opencode/src/lsp/client.ts
packages/opencode/src/lsp/client.ts:671-676
671 get diagnostics() {处理语言服务诊断。672 const result = new Map<string, Diagnostic[]>()673 for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {处理语言服务诊断。674 result.set(key, mergedDiagnostics(key))处理语言服务诊断。675 }676 return result返回给上一层。
packages/opencode/src/lsp/lsp.ts
packages/opencode/src/lsp/lsp.ts:368-379
368 const diagnostics = Effect.fn("LSP.diagnostics")(function* () {处理语言服务诊断。369 const results: Record<string, LSPClient.Diagnostic[]> = {}处理语言服务诊断。370 const all = yield* runAll(async (client) => client.diagnostics)处理语言服务诊断。371 for (const result of all) {遍历集合。372 for (const [p, diags] of result.entries()) {遍历集合。373 const arr = results[p] || []374 arr.push(...diags)375 results[p] = arr376 }377 }378 return results返回给上一层。379 })
Diagnostic.report 只保留 severity = 1 的 error,每个文件最多输出 20 条,并把 0-based line/character 转成 1-based 文本位置。见 packages/opencode/src/lsp/diagnostic.ts:3-26。
packages/opencode/src/lsp/diagnostic.ts
packages/opencode/src/lsp/diagnostic.ts:3-26
3const MAX_PER_FILE = 2045export function pretty(diagnostic: LSPClient.Diagnostic) {处理语言服务诊断。6 const severityMap = {7 1: "ERROR",8 2: "WARN",9 3: "INFO",10 4: "HINT",11 }1213 const severity = severityMap[diagnostic.severity || 1]处理语言服务诊断。14 const line = diagnostic.range.start.line + 1处理语言服务诊断。15 const col = diagnostic.range.start.character + 1处理语言服务诊断。1617 return `${severity} [${line}:${col}] ${diagnostic.message}`处理语言服务诊断。18}1920export function report(file: string, issues: LSPClient.Diagnostic[]) {处理语言服务诊断。21 const errors = issues.filter((item) => item.severity === 1)22 if (errors.length === 0) return ""按条件进入分支。23 const limited = errors.slice(0, MAX_PER_FILE)24 const more = errors.length - MAX_PER_FILE25 const suffix = more > 0 ? `\n... and ${more} more` : ""26 return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`处理语言服务诊断。
因此:
- 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:18-18、packages/opencode/src/tool/write.ts:74-90。
packages/opencode/src/tool/write.ts
packages/opencode/src/tool/write.ts:18-18
18const MAX_PROJECT_DIAGNOSTICS_FILES = 5
packages/opencode/src/tool/write.ts
packages/opencode/src/tool/write.ts:74-90
74 let output = "Wrote file successfully."75 yield* lsp.touchFile(filepath, "document")等待 Effect 结果。76 const diagnostics = yield* lsp.diagnostics()处理语言服务诊断。77 const normalizedFilepath = AppFileSystem.normalizePath(filepath)读写本地文件。78 let projectDiagnosticsCount = 0处理语言服务诊断。79 for (const [file, issues] of Object.entries(diagnostics)) {处理语言服务诊断。80 const current = file === normalizedFilepath81 if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue处理语言服务诊断。82 const block = LSP.Diagnostic.report(current ? filepath : file, issues)处理语言服务诊断。83 if (!block) continue按条件进入分支。84 if (current) {按条件进入分支。85 output += `\n\nLSP errors detected in this file, please fix:\n${block}`处理语言服务诊断。86 continue87 }88 projectDiagnosticsCount++处理语言服务诊断。89 output += `\n\nLSP errors detected in other files:\n${block}`处理语言服务诊断。90 }
这对创建公共类型或配置文件很有用:一个文件的变化可能让引用方报错。但它不是完整项目诊断保证:
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:381-482。
packages/opencode/src/lsp/lsp.ts
packages/opencode/src/lsp/lsp.ts:381-482
381 const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {处理语言服务诊断。382 return yield* run(input.file, (client) =>等待 Effect 结果。383 client.connection384 .sendRequest("textDocument/hover", {385 textDocument: { uri: pathToFileURL(input.file).href },386 position: { line: input.line, character: input.character },387 })388 .catch(() => null),389 )390 })391392 const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {处理语言服务诊断。393 const results = yield* run(input.file, (client) =>等待 Effect 结果。394 client.connection395 .sendRequest("textDocument/definition", {396 textDocument: { uri: pathToFileURL(input.file).href },397 position: { line: input.line, character: input.character },398 })399 .catch(() => null),400 )401 return results.flat().filter(Boolean)返回给上一层。402 })403404 const references = Effect.fn("LSP.references")(function* (input: LocInput) {处理语言服务诊断。405 const results = yield* run(input.file, (client) =>等待 Effect 结果。406 client.connection407 .sendRequest("textDocument/references", {408 textDocument: { uri: pathToFileURL(input.file).href },409 position: { line: input.line, character: input.character },410 context: { includeDeclaration: true },411 })412 .catch(() => []),413 )414 return results.flat().filter(Boolean)返回给上一层。415 })416417 const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {处理语言服务诊断。418 const results = yield* run(input.file, (client) =>等待 Effect 结果。419 client.connection420 .sendRequest("textDocument/implementation", {421 textDocument: { uri: pathToFileURL(input.file).href },422 position: { line: input.line, character: input.character },423 })424 .catch(() => null),425 )426 return results.flat().filter(Boolean)返回给上一层。427 })428429 const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {处理语言服务诊断。430 const file = fileURLToPath(uri)431 const results = yield* run(file, (client) =>等待 Effect 结果。432 client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),433 )434 return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean)返回给上一层。435 })436437 const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {处理语言服务诊断。438 const results = yield* runAll((client) =>等待 Effect 结果。439 client.connection440 .sendRequest<Symbol[]>("workspace/symbol", { query })441 .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10))442 .catch(() => [] as Symbol[]),443 )444 return results.flat()返回给上一层。445 })446447 const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {处理语言服务诊断。448 const results = yield* run(input.file, (client) =>等待 Effect 结果。449 client.connection450 .sendRequest("textDocument/prepareCallHierarchy", {451 textDocument: { uri: pathToFileURL(input.file).href },452 position: { line: input.line, character: input.character },453 })454 .catch(() => []),455 )456 return results.flat().filter(Boolean)返回给上一层。457 })458459 const callHierarchyRequest = Effect.fnUntraced(function* (Effect 异步工作流。460 input: LocInput,461 direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",462 ) {463 const results = yield* run(input.file, async (client) => {等待 Effect 结果。464 const items = await client.connection465 .sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {466 textDocument: { uri: pathToFileURL(input.file).href },467 position: { line: input.line, character: input.character },468 })469 .catch(() => [] as unknown[])470 if (!items?.length) return []按条件进入分支。471 return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])返回给上一层。472 })473 return results.flat().filter(Boolean)返回给上一层。474 })475476 const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {处理语言服务诊断。477 return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")等待 Effect 结果。478 })479480 const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {处理语言服务诊断。481 return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")等待 Effect 结果。482 })
多数查询失败会被降级为 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 条 |
touchFile 与 diagnostics 聚合
packages/opencode/src/lsp/lsp.ts:346-379
touchFile 捕获异常,因此 LSP 故障通常不会回滚已经完成的文件修改。
346 const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {处理语言服务诊断。347 log.info("touching file", { file: input })348 const clients = yield* getClients(input)等待 Effect 结果。349 yield* Effect.promise(() =>Effect 异步工作流。350 Promise.all(并行等待多个任务。351 clients.map(async (client) => {352 const after = Date.now()353 const version = await client.notify.open({ path: input })354 if (!diagnostics) return处理语言服务诊断。355 return client.waitForDiagnostics({处理语言服务诊断。356 path: input,357 version,358 mode: diagnostics,处理语言服务诊断。359 after,360 })361 }),362 ).catch((err) => {363 log.error("failed to touch file", { err, file: input })364 }),365 )366 })367368 const diagnostics = Effect.fn("LSP.diagnostics")(function* () {处理语言服务诊断。369 const results: Record<string, LSPClient.Diagnostic[]> = {}处理语言服务诊断。370 const all = yield* runAll(async (client) => client.diagnostics)处理语言服务诊断。371 for (const result of all) {遍历集合。372 for (const [p, diags] of result.entries()) {遍历集合。373 const arr = results[p] || []374 arr.push(...diags)375 results[p] = arr376 }377 }378 return results返回给上一层。379 })
“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 闸门:权限、审批与安全边界。