青雲的博客
深入浅出 Pi 第八部:终端之上,工程边界之下 第 46 章

流式消息与工具结果怎样出现在屏幕上

沿着 AgentEvent、AgentSessionEvent 与 InteractiveMode.handleEvent 追踪一次流式回复,说明 assistant component、toolCallId 映射、状态提示和会话重建如何把运行事实投影成终端画面。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

第 45 章停在 TUI.requestRender():组件给出新行,TUI 决定局部更新还是全量重画。但“组件里的内容为什么变了”还没有回答。一次模型回复至少会经过 assistant 文本增量、tool call 参数增量、工具开始、partial result、最终 result 和本轮结束。终端没有直接订阅 provider stream;它订阅的是 AgentSession 对外发出的事件,再把这些事件投影到组件状态。

这里的“投影”不是修辞。运行时消息、会话记录和屏幕组件是三份生命周期不同的数据:provider stream 产生事实,AgentSession 负责把应持久化的消息写进 session,InteractiveMode 只维护当前可见状态。只要持久层还在,界面组件可以全部销毁后重建;反过来,屏幕上刚出现一个 tool block,并不能单独证明最终 result 已经写入会话。

AgentSessionEvent 比 AgentEvent 多了一层控制面

底层 AgentEvent 描述 agent loop:agent_start、三段消息事件、三段工具执行事件和 agent_endAgentSessionEvent 保留这些事件,但给 agent_end 增加 willRetry,并补上 queue、compaction、session info、thinking level、auto retry、summarization retry、bash update 与 agent_settled。TUI 因而不需要同时观察 Agent、SessionManager 和 retry timer,它只订阅一个出口。

事件出口仍然保留明确顺序。_handleAgentEvent() 先处理 steering/follow-up 队列消费,再将事件交给 extensions,随后通知包括 TUI 在内的 listeners;message_end 之后才按 message role 追加 session entry。也就是说,listener 收到 message_end 的那个瞬间,内存中的完成消息已经存在,但对应的 append 紧跟在 listener 通知之后执行。不能把“UI 已刷新”写成“持久化一定早已完成”,更不能从一帧屏幕恢复事务顺序。

flowchart LR
  accTitle: 一次流式回复从运行事件到终端画面
  accDescr: Provider stream 经 agent loop 形成消息与工具事件,AgentSession 统一转发并负责应持久化消息,InteractiveMode 更新临时组件,TUI 再把组件行差分写到终端。
  PROVIDER["provider stream"] --> LOOP["agent loop events"]
  LOOP --> SESSION["AgentSession"]
  SESSION --> EXT["extension listeners"]
  SESSION --> VIEW["InteractiveMode.handleEvent"]
  SESSION --> STORE["SessionManager append"]
  VIEW --> COMPONENTS["streamingComponent + pendingTools"]
  COMPONENTS --> RENDER["TUI.requestRender"]
  RENDER --> TERM["terminal diff"]

一条 assistant 消息只有一个流式组件

InteractiveMode.subscribeToAgent() 注册一个异步 listener,所有事件进入同一个 handleEvent() switch。收到 assistant 的 message_start 时,它创建空的 AssistantMessageComponent,同时保存 streamingMessage,加入 chatContainer 并请求渲染。后续每个 message_update 都用事件携带的完整 message 更新这两个引用,而不是把 delta 手工拼到终端字符串末尾。

这个选择解决了 thinking、Markdown 和 tool call 交错的问题。组件每次从当前 message content 重新计算可见内容;如果半个 Markdown fence 在下一次 update 才闭合,渲染器能重新解释整个块。TUI 的 16ms 调度再合并密集更新,所以 provider 可以细粒度发事件,终端不必逐 token 同步写 stdout。

message_end 会最后更新一次组件。若 stop reason 是 abortederror,尚未完成的工具组件被写入错误结果并清空;正常结束则把参数标记为 complete,让 edit 等 renderer 在完整参数上计算 diff。最后才清除 streamingComponentstreamingMessage 引用。屏幕里的 assistant 组件仍留在 chatContainer,消失的是“它还在流式变化”的临时身份。

toolCallId 把两条事件链合到同一块画面

工具组件可能在两个时点首次出现。模型流式给出 content.type === "toolCall" 时,message_update 已经能按名称、id 和当前 arguments 建组件;有些路径直到 tool_execution_start 才第一次见到它,于是 start 分支会补建。两条路径最终都写入 pendingTools: Map<toolCallId, ToolExecutionComponent>

必须用 id,而不是工具名。一个 assistant message 可以并行请求两个 read,名称相同、参数和结果不同。tool_execution_updatetoolCallId 找到唯一组件,把 partial result 标为 still streaming;tool_execution_end 再写最终 result、设置 isError,并从 map 删除。map 的“pending”表示界面仍期待最终工具事件,不等于工具执行队列本身。

bash_execution_update 在这个 switch 里反而什么也不做,注释说明用户 ! bash 的 callback 已直接更新对应 TUI 组件。它提醒我们:AgentSessionEvent 是统一订阅面,不代表每种可见内容都必须在同一个 switch 分支修改。判断画面从哪里来,要继续追具体 component 的所有者,不能只数 event type。

状态栏也是事件投影,不是运行锁

agent_start 清空旧 pendingTools、打开 terminal progress 并显示 working indicator;agent_end 关闭 progress、移除残留 streaming component、清空 pendingTools;agent_settled 才检查延迟关闭请求。compaction 和 retry 事件则替换 status indicator,并临时改写 Escape handler,让用户可以中止当前操作。

因此,spinner 可见不等于 Agent 内部有一把 isRunning 锁,spinner 消失也不构成 durability checkpoint。状态栏只是根据离散事件维护的用户提示。真正的 busy、abort、retry 和 shutdown 所有权仍在 AgentSession 与 Agent;TUI 只把允许的控制动作绑定到当前编辑器。

恢复时不回放每个 delta,而是从会话重建

session replacement、启动恢复或 compaction 后,Pi 不需要保存 AssistantMessageComponentrenderCurrentSessionState() 清空资源、chat、pending messages、streaming refs 与 pendingTools,然后调用初始消息渲染。rebindCurrentSession() 先取消旧订阅、应用新 runtime settings,再重绑 extension 和新 session listener,避免旧会话后到的事件污染当前画面。

重建路径遍历 RenderSessionItem。assistant 中每个 toolCall 先产生工具组件;后来的 toolResult 再按 toolCallId 填回它。若只看到 call 没看到 result,该组件重新进入 pendingTools;aborted/error assistant 则直接得到错误结果。cache-miss notice 明确不持久化,而是从完整 entries 重新推导。这是一条很重要的验证准则:能从 session items 重建的才是会话事实;重建时重新推导或完全消失的,只是展示状态。

可以用下面的静态实验同时查看 live projection 与 rebuild projection。它不要求 provider key,也不会执行工具:

repo="${PI_RELEASE_SOURCE_DIR:-/path/to/pi-0.83.0}"
cd "$repo"

sed -n '2911,3054p' \
  packages/coding-agent/src/modes/interactive/interactive-mode.ts
sed -n '3346,3429p' \
  packages/coding-agent/src/modes/interactive/interactive-mode.ts

先对照 message_update -> pendingTools.set()tool_execution_end -> pendingTools.delete(),再对照重建时 assistant toolCall -> renderedPendingToolstoolResult -> updateResult()。两条链使用同一个关联键,却拥有不同输入:前者吃实时事件,后者吃已保存的 session items。这正是“组件是投影”的源码证据。

到这里,输入、运行事件和终端输出已经闭合。不过这些工具事件背后的 bashwrite 或 extension 到底能碰哪些系统资源,TUI 并不决定。第 47 章把 Project Trust、进程权限和外部隔离拆开,回答 Pi 所说的“信任项目”为什么绝不等于“把工具关进沙箱”。