一条消息的生命周期

在默认 TUI 里敲下回车之后:SDK 请求穿过 RPC 隧道进入 HttpApi,SessionPrompt 主循环调模型、跑工具、问权限,部件更新再经事件总线回到屏幕。

作者 David更新于 第 5 篇(共 38 篇)

这一篇把各层串起来走一遍:默认 opencode 命令下,一条用户输入从键盘到屏幕的完整路径。细节留给后面各篇。

图表加载中…

1. TUI 提交

提交逻辑在 packages/tui/src/component/prompt/index.tsx。没有当前会话就先 session.create(带上 Agent 与模型),然后分三路:Shell 模式调 session.shell,以已知命令名开头的输入调 session.command,其余调 session.prompt,parts 里依次放编辑器上下文、正文文本和附件(index.tsx:1094)。这个调用前面没有 await:请求一发出,输入框就清空,之后看到的所有进度都来自事件,而不是这个请求的返回值。

2. SDK 与隧道

生成的 SDK(packages/sdk/js/src/v2/gen/sdk.gen.ts:3742)把调用变成 POST /session/{sessionID}/message,工作目录经请求头 x-opencode-directory 传递。默认 TUI 的 fetch 是 RPC 隧道:请求被序列化给 Worker,由 Server.Default().app.fetch 在进程内处理(见启动链路)。

3. 路由与处理函数

端点定义在 packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:316,标识 session.prompt,载荷是去掉 sessionID 的 PromptInput。处理函数(packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:295)确认会话存在后调用 SessionPrompt.prompt,把最终消息序列化成 JSON 返回——这个 HTTP 请求要等整轮对话结束才返回。另一个端点 prompt_async 把同样的调用 fork 出去,立即回 204。

4. SessionPrompt.prompt

packages/opencode/src/session/prompt.ts:1052:取会话、revert.cleanup 收尾撤销状态、createUserMessage、刷新会话时间;noReply 为真就只落消息不回复,否则进入 loop。

createUserMessage(prompt.ts:635)确定 Agent(未指定用默认 Agent)与模型(请求优先,其次 Agent 配置,最后是会话当前模型),再展开输入部件:文件部件会被真正读出来,变成一段合成的“Called the Read tool...”文本加读取结果(prompt.ts:860),MCP 资源与 @agent 提及也各有处理;最后逐条 updateMessage、updatePart 落库。

5. loop 与 runLoop

loop 只有一行(prompt.ts:1346):

return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))

SessionRunState 给每个会话配一个 Runner(packages/opencode/src/effect/runner.ts):空闲时启动 runLoop,已在运行时新调用只等待同一个结果(runner.ts:120)。所以会话忙时再发消息不会开第二个循环——新消息已经落库,正在跑的 runLoop 下一轮重读消息时自然会看到它。

runLoop(prompt.ts:1081)每一轮:状态置为 busy、重读消息、判断能否退出;处理子任务与压缩任务;创建空的 assistant 消息与 SessionProcessor;用 SessionTools.resolve 备好工具;拼好系统提示词后调用 handle.process(...)。退出条件旁的注释值得一读(prompt.ts:1103):

Some providers return "stop" even when the assistant message contains tool calls. Keep the loop running so tool results can be sent back to the model, but ignore cleanup-marked interrupted orphans.

LLM.run 调 streamText 时没有设置 stopWhen(AI SDK 默认只跑一步),工具结果回灌给模型靠的正是 runLoop 的下一轮。

6. 调模型

SessionProcessor.process(packages/opencode/src/session/processor.ts:641)订阅 llm.stream(...),逐个事件交给 handleEvent,出错按重试策略重来,最后给外层一个三值结论(processor.ts:693):

if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"

LLM.stream(packages/opencode/src/session/llm.ts:357)默认走 AI SDK 的 streamText,把 fullStream 经 LLMAISDK.toLLMEvents 转成统一的 LLMEvent;设了 OPENCODE_EXPERIMENTAL_NATIVE_LLM 且模型受支持时,改由 @opencode-ai/llm 直接产出 LLMEvent。处理器分不出两者,见模型调用层。

7. 工具与权限

交给 streamText 的每个工具都由 SessionTools.resolve 包过:execute 先触发插件钩子 tool.execute.before,再调工具本体,最后 tool.execute.after(packages/opencode/src/session/tools.ts:102)。工具需要授权时调用上下文里的 ask,它把 Agent 与会话的规则合并后交给权限服务(tools.ts:81)。

Permission.ask(packages/opencode/src/permission/index.ts:67)逐个模式求值:命中 deny 直接失败,全是 allow 就放行,否则挂起(index.ts:98):

const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>()
pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
  Deferred.await(deferred),
  Effect.sync(() => {
    pending.delete(id)
  }),
)

工具就停在这个 Deferred 上:permission.asked 事件让 TUI 弹出询问,用户选择后调 sdk.client.permission.reply(POST /permission/:requestID/reply),Permission.reply 完成或拒绝它。选“始终允许”会记下规则,并放行同会话里其他已满足的挂起请求;拒绝则让同会话的挂起请求一起失败。以 --auto 启动时,packages/tui/src/context/sync.tsx:196 收到询问就自动回复 once。

处理器同时把进展写成 tool 部件:tool-input-start 建一个 pending 部件,tool-call 转为 running,tool-result 或 tool-error 收尾(processor.ts:315 起)。同一工具连续三次同参调用会触发 doom_loop 询问;默认配置下工具被拒绝会让处理器返回 stop,本轮结束。

8. 部件回到屏幕

每次 Session.updatePart 都发布 message.part.updated(packages/opencode/src/session/session.ts:635);文本与推理的增量走 updatePartDelta,发布 message.part.delta。看定义(packages/schema/src/v1/session.ts):前者带 durable 声明,会被 core 的 SessionProjector 写进 SQLite;后者没有,只服务于界面上的逐字显示。

事件从这里出发回到 TUI:

  1. EventV2Bridge 监听所有事件,转成 GlobalBus.emit("event", ...) 并附上目录、项目与工作区(packages/opencode/src/event-v2-bridge.ts:35)。
  2. Worker 订阅 GlobalBus,经 Rpc.emit("global.event", ...) 推回主线程;对外监听时改由 /global/event 的 SSE 端点推送,另带 10 秒一次的心跳。
  3. TUI 的 SDK 上下文把事件排队,16 毫秒内到达的合并成一批,用 Solid 的 batch 一次应用(packages/tui/src/context/sdk.tsx:68)。
  4. packages/tui/src/context/sync.tsx 按类型更新 store:message.part.updated 二分查找后插入或替换,message.part.delta 把增量拼到对应字段,permission.asked 进入待回复列表。
  5. 会话页把 sync.data.part[message.id] 交给 AssistantMessage 组件渲染(packages/tui/src/routes/session/index.tsx:1289)。

收尾时,step-finish 记下用量、费用与快照补丁;runLoop 退出后 Runner 把状态置回 idle,session.status 事件让界面结束忙碌,那个一直挂着的 HTTP 请求这才带着最后一条 assistant 消息返回。同一套事件也经 SSE 喂给 Web、桌面端与 attach 进来的客户端——请求只负责发起,进度全走事件。

下一篇:会话与消息模型——会话、消息与各种部件的数据结构,以及它们怎样落进 SQLite。

本页目录