一条消息的生命周期
在默认 TUI 里敲下回车之后:SDK 请求穿过 RPC 隧道进入 HttpApi,SessionPrompt 主循环调模型、跑工具、问权限,部件更新再经事件总线回到屏幕。
这一篇把各层串起来走一遍:默认 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:
EventV2Bridge监听所有事件,转成GlobalBus.emit("event", ...)并附上目录、项目与工作区(packages/opencode/src/event-v2-bridge.ts:35)。- Worker 订阅
GlobalBus,经Rpc.emit("global.event", ...)推回主线程;对外监听时改由/global/event的 SSE 端点推送,另带 10 秒一次的心跳。 - TUI 的 SDK 上下文把事件排队,16 毫秒内到达的合并成一批,用 Solid 的
batch一次应用(packages/tui/src/context/sdk.tsx:68)。 packages/tui/src/context/sync.tsx按类型更新 store:message.part.updated二分查找后插入或替换,message.part.delta把增量拼到对应字段,permission.asked进入待回复列表。- 会话页把
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。