PR #150:把 Session 从“最后一条消息在动”改成可重放的播放系统

buffin-ai/buffin · 4afb572...40bb96a · 2026-07-16 · 自包含,读完即弃

10 commits
54 文件
+3,031 / −265
60.9% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。全部事实均链接到 PR #150、对应 commit/blob 或 CI 原始材料;无法由 PR 证明的原因标为 uncertain

1TL;DR

这个 PR 同时补齐了两端:daemon 让 message/tool 增量在 run terminal 前稳定落库,Desktop 把这些事件折叠成可重放的工具输出、control receipt 和消息 span;在事实投影之上,新的 presentation 层再决定当前表面应当 staticrevealing 还是 frozen。因此,历史回放不会重演动画,审批暂停不会吞掉 reveal 进度,隐藏 tab 继续接收事件但不耗费动画,终止中的工具仍保留部分输出。原始材料:PR 描述与完整 diff

这不是完整的“新 Session 引擎”:共享 API 事件联合、显式 caught-up marker、cursor activation barrier、五个独立 projector 和 Streamdown 对照 adapter 都没有进入 #150;当前同步边界仍来自既有 hydration 计时启发式。原始材料:session-runtime-store.ts · 前四个提交的随附走读

2变更地图

apps/desktop
2,678 行 · 81.2%
apps/daemon
618 行 · 18.8%

按 numstat 计算,测试文件承载 2,007 行变更,非测试代码 1,289 行;测试占比是 60.9%。PR 没有 rename,也没有数据库、migration、依赖或 API 包变更。原始材料:PR Files

子系统设计重心(要细读)可放心略过来源
daemon / sinkpendingToolFlushesflushTerminalDeltasretireRun 定义 durable terminal 顺序。toolkit 的 barrel re-export。SessionSink
desktop / projectionToolOutputChunk、control receipt、message span closedstreamingClosed三份 locale 只新增同一个“输出已截断”文案。session-store
desktop / presentationSessionDomainThreadPresentation 把 run、同步、可见性、reduced motion 合成展示策略。共享 padding 常量是密度接线,不改变状态语义。thread-presentation
desktop / rendererIncremarkAdapter、active part 路由、ToolOutput、ControlReceipt、MutationObserver 自动滚动。Composer 的 padding、单行窄布局和 surface class 属于视觉收口。Incremark adapter

3架构一图流

以前 · 事实与动画挤在组件布尔值里

SessionSink
只 drain message
run terminal
Session store
忽略 tool.delta / control
可见输出
Thread
isRunning + 最后一组
Markdown

现在 · 先投影事实,再派生播放

Terminal drain
message + tool + seal
durable log
Session projection
message parts
domain
Presentation
static / reveal / freeze
Adapter

结构变化的关键不是换 Markdown 库,而是把“事件事实”“Session 领域状态”“某个挂载表面的播放方式”拆成三层。原始材料:SessionContent.tsx · session-domain.ts

4数据与状态先行

4.1工具输出从字符串变成带身份的片段

apps/desktop/.../models/session-store.ts原始文件
export interface ToolOutputChunk {
  eventId: string
  output: unknown
  stream?: 'stdout' | 'stderr' | undefined
  seq?: number | undefined
  truncated?: boolean | undefined
}

export interface MessagePart {
  // ...
  streamingOutput?: ToolOutputChunk[] | undefined
  streamingClosed?: boolean | undefined
  result?: { output: unknown; status: 'completed' | 'failed' } | undefined
}

eventId 兜底去重没有 provider sequence 的片段;有 seq 时,stdout 与 stderr 分流排序。result 到达后仍是权威终态,streaming 字段会被移除。原始材料:projection tests

4.2领域状态与展示状态分开

apps/desktop/.../models/session-domain.ts + thread-presentation.ts原始文件
export type SessionExecutionPhase =
  | 'idle'
  | 'running'
  | 'waiting-approval'
  | 'waiting-input'
  | 'settled'

export type ThreadPlaybackMode = 'static' | 'revealing' | 'frozen'

export interface ThreadPresentation {
  execution: SessionExecutionPhase
  activeMessageId: string | null
  activePartIndex: number | null
  playback: ThreadPlaybackMode
  animateEntries: boolean
}

SessionExecutionPhase 描述 run 与人机交互的领域阶段;ThreadPlaybackMode 只描述当前表面的动画策略。一个隐藏表面和一个可见表面可以读同一 Session projection,却得到不同播放结果。原始材料:content visibility

4.3daemon 为每个 run 维护 terminal barrier

apps/daemon/src/agent/session-sink.ts + tool-delta-coalescer.ts原始文件
private readonly pendingToolFlushes =
  new Map<string, Set<Promise<unknown>>>()

async flushTerminalDeltas(opts?: { runId?: string }): Promise<void> {
  await this.flushMessageDeltas(opts)
  await this.flushToolDeltas(opts)
  if (opts?.runId) this.toolDeltas.retireRun(opts.runId)
}

Map 收集“定时器已经发出、但数据库 append 尚未结束”的写入;retiredRuns 则在排空后封印 run,拒绝迟到 provider callback 重新开 buffer。原始材料:ToolDeltaCoalescer

5底座:投影事实与展示策略

三条旅程共用一个规则:reducer 只记录“发生了什么”,presentation 只回答“这个表面现在怎么显示”,adapter 只把后者翻译成渲染库命令。这样 replay 与 live 可以产生相同的 Session projection,而动画不会反向污染 durable 事实。原始材料:session-store · Incremark adapter

新增一种可播放内容的标准步骤:先让事件落进 SessionEvent 与纯 reducer;再在 domain/presentation 中明确它是否拥有 live tail;最后由 renderer/adapter 消费已经算好的 playback。不要在组件里重新扫描全局消息猜 run 状态。原始材料:SessionThread.tsx

control.completed 展示了这个配方的非动画分支:reducer 用 event envelope id 保证 replay idempotence;有开放 assistant message 就附在其中,否则创建一条独立 complete message;ControlReceipt 只负责显示 ok、unsupported、error 与可选输出。原始材料:ControlReceipt.tsx

6旅程 A:一段工具输出如何穿过 run terminal

这一条旅程解决的是 durable ordering:工具输出不能因为 coalescer 阈值、定时器并发、interrupt 或 crash 而落在 run.completed 之后。否则桌面 replay 会先把工具标成停止,再收到“迟到的运行中输出”。原始材料:终端工具输出修复 commit

全景 · 涉及 6 个核心文件
buffer / timer
tool-delta-coalescer.ts
durable barrier
session-sink.ts
event log / WS replayprojection
session-store.ts
ToolOutput
ToolOutput.tsx

A.1定时器派发的写入也进入 terminal barrier

旧 drain 只能取到仍在 buffer 里的 message delta。tool timer 一旦触发,buffer 已被取走,但异步 emit() 可能还没落库;这段时间就是 terminal 的盲区。现在每个写入 Promise 都挂到所属 runId 下。原始材料:base SessionSink · PR head SessionSink

apps/daemon/src/agent/session-sink.ts原始文件
const write = this.emit(event, { runId }).catch((err) => {
  logger.error({ err, runId, toolCallId: event.toolCallId },
    'dropped a coalesced tool.delta after a background flush write failed')
})
const pending = this.pendingToolFlushes.get(runId)
  ?? new Set<Promise<unknown>>()
pending.add(write)
this.pendingToolFlushes.set(runId, pending)
void write.finally(() => {
  pending.delete(write)
  if (pending.size === 0) this.pendingToolFlushes.delete(runId)
})

waitForToolFlushes() 用循环而不是一次 Promise 快照:等待期间若又出现一笔 timer write,下一轮仍会看到它。排空顺序是 message → 等待 timer tool writes → residual tool buffers → seal run → terminal。原始材料:并发顺序测试

以前
只排空 message delta
run terminal 先落库
timer tool write 可能迟到
现在
排空 message delta
等待并排空该 run 的 tool delta
seal run 后再落 terminal

A.2Desktop reducer 保留流式事实,终态输出仍有最高权威

tool.delta 先找到同 toolCallId 的 part,再按 event id 去重;带 sequence 时仅在同一 stream 内排序。tool.completed 到达后,reducer 删除 streaming 字段并写入 authoritative result。原始材料:session-store.ts

apps/desktop/.../models/session-store.ts原始文件
const output = insertToolOutputChunk(existing.streamingOutput ?? [], chunk)
if (output === existing.streamingOutput) return
parts[index] = { ...existing, streamingOutput: output }

// tool.completed
const { streamingOutput: _output, streamingClosed: _closed, ...settled } = existing
parts[i] = {
  ...settled,
  result: { output: event.output, status: event.status },
}

如果 run terminal 到达时工具没有自己的 tool.completed,reducer 设 streamingClosed=true,保留部分输出但移除 running spinner。ToolOutput 展示 stdout/stderr 来源和截断标记;Bash 与 generic ToolRow 共用它。原始材料:ToolOutput.tsx

排查路标 · 旅程 A
症状从哪下手
run.completed 后还出现新的 tool outputsession-sink.ts:看 pendingToolFlushes / waitForToolFlushestool-delta-coalescer.ts:看 retireRun
interrupt 后 partial output 消失service.ts:看 flushLiveTerminalDeltassession-store.ts:看 handleRunCompletedstreamingClosed
stdout 顺序错或 replay 重复session-store.ts:看 insertToolOutputChunk 的 eventId 与 per-stream seq 规则。
终态仍显示 partial,而非 final outputhandleToolCompleted:确认 streaming 字段被移除、result 成为权威。

7旅程 B:一条 Session 里只有一个叙事尾部能播放

旧组件用“当前正在 running + 最后一组”决定 typewriter。这个规则无法区分 replay、隐藏 tab、旧的 running message、pending approval,以及 tool 已经接管 live tail。新路径先选 active message,再选 active narrative part,最后才计算 playback。原始材料:base SessionThread · PR head

全景 · 涉及 5 个核心文件
runtime projection
use-session-store
domain
session-domain.ts
visibility + reduced motionpresentation
thread-presentation.ts
active group
SessionThread.tsx

B.1领域状态只让开放 run 的 assistant message 拥有交互

deriveSessionDomain() 从后往前找最后一个 assistant + running message。approval/input 只有位于这条 active message 内且仍 pending,才会把 execution 改成 waiting;旧 completed message 留下的 stale pending receipt 不再让整个 Session 错报 attention。原始材料:session-domain tests

apps/desktop/.../models/session-domain.ts原始文件
const activeMessage = findLastRunningMessage(messages)
const pending = activeMessage
  ? findLatestPendingInteraction(activeMessage.parts)
  : undefined
return {
  execution:
    pending?.type === 'approval' ? 'waiting-approval'
    : pending?.type === 'ask-user' ? 'waiting-input'
    : 'running',
  activeMessageId: activeMessage?.id ?? null,
}

B.2同步、可见性和 reduced motion 在播放前先否决

Workbench 以前只通过 CSS/ARIA 隐藏 inactive tab。现在 ContentVisibilityProvider 把 tab surface 的 visible 与 document.visibilityState 合并,再交给 presentation;组件保持挂载,既有 active-subscription union 继续订阅所有 open/preview Session,但隐藏时 playback 强制 static。原始材料:ContentHost.tsx · active subscriptions · content visibility

apps/desktop/.../models/thread-presentation.ts原始文件
function derivePlaybackMode(input, execution): ThreadPlaybackMode {
  if (!input.visible || input.isHydrating || input.reduceMotion) return 'static'
  if (execution === 'waiting-approval' || execution === 'waiting-input') {
    return 'frozen'
  }
  return execution === 'running' ? 'revealing' : 'static'
}

findActiveNarrativePartIndex() 还会检查 live tail:running 状态下,text/reasoning 若不是 message 的最后一个 part,就返回 null,因为后面的 tool 已接管实时尾部。审批/输入是例外,它们让前一段 narrative 保留 cursor,并切成 frozen。原始材料:thread-presentation tests

排查路标 · 旅程 B
症状从哪下手
切到后台后仍然播放文字动画ContentHost.tsxcontent-visibility.tsx:确认 surface/window visible;再看 derivePlaybackMode
旧审批卡让 tab 一直显示 attentionsession-domain.ts:确认 pending 只从 active running assistant message 读取。
tool 运行时前一段文字仍继续 revealfindActiveNarrativePartIndex:tool 是否已成为 message 的最后一个 part。
同一 Session 有两条回复同时显示 spinnerSessionThread.tsx:只有 activeMessageId 可以得到 running indicator。

8旅程 C:playback 如何驱动 Incremark 与滚动

MarkdownPart 现在只接收完整权威文本、playback 和 finished;所有 parser/typewriter 命令收进 IncremarkAdapter。这让上层不再暴露 Incremark 的 append/reset/finalize 生命周期。原始材料:MarkdownPart.tsx

全景 · 涉及 4 个核心文件
ThreadPresentation
playback + finished
MarkdownPart
产品接口
IncremarkAdapter
parser / typewriter
scroll observer
use-auto-scroll-bottom.ts

C.1revealing、frozen、static 分别对应 resume、pause、skip

apps/desktop/.../components/incremark-adapter.tsx原始文件
if (playback === 'revealing') {
  typewriter.setEnabled(true)
  typewriter.resume()
} else if (playback === 'frozen' && typewriter.enabled) {
  typewriter.pause()
} else {
  typewriter.setEnabled(false)
  typewriter.skip()
}

frozen 不调用 skip,所以 approval/input 暂停期间新 append 可以进 parser 队列,但不会越过当前 reveal 光标;恢复 running 后从原位置继续。static 则立即跳到权威内容,适用于 replay、hidden、settled 与 reduced motion。原始材料:adapter unit tests

C.2前缀增长 append,权威修正 reset;finished 才 finalize

message delta 的正常增长只 append 后缀;若 message.completed.text 给出更短、等长或中间修正的内容,adapter reset 后 append 全文,并 skip 二次动画。组件 unmount 不等于内容完成,因为 unmount 可能只是 StrictMode rehearsal;只有 finished 才 finalize 一次。原始材料:真实 Incremark 回归测试

apps/desktop/.../components/incremark-adapter.tsx原始文件
if (text.length === 0) {
  im.reset()
} else if (initialContent || text.startsWith(previous)) {
  im.append(text.slice(previous?.length ?? 0))
} else {
  im.reset()
  im.append(text)
  im.typewriter.setEnabled(false)
  im.typewriter.skip()
}

if (finished && !finalizedRef.current) im.finalize()

自动滚动也分成两个来源:React state 更新在 useLayoutEffect 里 paint 前 pin;Incremark typewriter 自己修改 DOM 时,MutationObserver 在下一 animation frame pin。用户向上滚离底部后 followingRef=false,两条路径都停止;回到底部才恢复。原始材料:use-auto-scroll-bottom.ts

排查路标 · 旅程 C
症状从哪下手
标题只显示早期短前缀,后续 append 不进 DOMincremark-adapter.tsx:看 sourceBlocks 对 completed block 的替换;对照真实库测试。
审批暂停后文字瞬间补全看 frozen 分支是否只 pause,没有 skip
completed 修正文案重新打一遍看 replacement 分支的 reset + append + skip
流式 DOM 变高但 viewport 不跟随use-auto-scroll-bottom.ts:看 MutationObserver、rAF 与 followingRef

9计划 vs 实现的偏差

PR 作者随附的架构稿比 #150 的实际范围更大;本地原稿没有进入 PR diff,因此下面“计划”同时链接到已经发布的前四提交走读,实际状态则链接到 PR head。范围为什么在实现中收窄或改序,commit message 没有逐项解释,原因标为 uncertain原始材料:前四提交随附走读 · PR commits

计划#150 实际落地偏差说明
完整 Agent event union 移入 @buffin/api,Desktop 不再维护子集。session-store.ts 仍定义本地 union,只新增 tool.deltacontrol.completed共享契约未改;为什么延后是 uncertain源码
五个纯 projector 按 run/item/tool 等稳定 id 相关联。继续在现有 reducer 中把事件写入最后一条 open assistant message,新增 tool/control handlers。先扩充现有 projection,不是目标模块化终态;原因 uncertain源码
transport 发显式 caught-up marker,并以 cursor activation barrier 区分历史与 live。仍用 200ms quiet / 2s cap 的 isHydrating;可见性切换没有 cursor barrier。presentation 已可替换,transport 边界仍是启发式。源码
先做 Streamdown reference adapter,再与 Incremark fixture benchmark。只落 Incremark adapter,完整支持 append、replace、freeze、static、finalize。没有引入 Streamdown;为什么跳过 benchmark 是 uncertain源码
CJK 先做逐边界诊断,再根据首个红点修生产链。诊断门覆盖 stdout、translator、codec、coalescer、superjson、Desktop reducer;生产编码路径没有变化。现有链在测试语料下保持 byte/code-point intact;原始视觉问题根因仍 uncertain测试
Session UI 路线图分多个 release group,不把 19 项塞进一个 PR。#150 合并了播放状态、tool output、control receipt、CJK/Reasoning gates、密度、Composer 窄布局与滚动。仍未包含 PayloadViewer、Tool Matcher、结构化 failure、queue、attachment 与 workspace;具体合并原因 uncertainPR files

10心智模型补丁

run terminal 只需要排空 assistant 文本。terminal 前必须排空 message 与 tool 两类增量,还要等待已由定时器派发的 durable writes。
tool.completed 是工具输出第一次可见的时刻。tool.delta 在运行中可见;tool.completed 只负责用权威结果收束 streaming state。
messages 最后一条就是当前 agent 回复。当前回复是最后一个 running assistant message;它后面可以有 user 或 complete message。
running 就意味着应该播放动画。running 只描述领域状态;hydration、visibility、reduced motion 和 active tail 都能否决播放。
暂停动画就是把全文静态显示。frozen 保留 reveal 光标;只有 static 会 skip 到当前权威全文。
切 tab 会卸载 Session,所以动画自然停止。tab keep-alive;宿主显式提供 visible,projection/订阅继续更新,动画单独静态化。
component unmount 是 Markdown parser 的自然完成点。只有领域内容 finished 才 finalize;unmount 可能只是 StrictMode rehearsal 或表面生命周期。

11新词表

daemon ordering
terminal drain在 run terminal 前排空所有必须先落库的 message/tool 增量。来源
retired run已经 terminalize、在当前 sink 生命周期内拒绝迟到工具输出的 run。来源
timer-dispatched writetimer 已取走 buffer 并调用 emit,但 durable append 尚未结束的异步写入。来源
projection
ToolOutputChunk带 envelope id、可选 stream/seq/truncated 的一段运行中工具输出。来源
streamingClosedrun 已结束但工具没发 tool.completed;保留 partial output,同时不再显示 running。来源
control receipt由 control.completed 投影出的 replay-safe 终态命令回执。来源
presentation
SessionDomain从 message projection 与 isRunning 得出的当前执行阶段,不包含动画。来源
ThreadPresentation某个挂载表面当前的 active message、active part、playback 与 enter animation 快照。来源
frozenparser 可继续接收内容,但 typewriter 停在当前 reveal 光标,不 skip 到全文。来源

12测试与风险地图

有兜底的
  • terminal 同时排空 message/tool,并等待 timer write 后再写 residual 与 run/tool terminal。测试
  • retireRun 封印单个 run,不影响另一个 live run。测试
  • CJK corpus 覆盖 UTF-8 split、Claude translator、codec replay、coalescer、superjson 与 Desktop reducer。测试
  • tool chunk 排序/去重、终态覆盖、partial close、control receipt replay idempotence。测试
  • domain/presentation 覆盖 replay、hidden、reduced motion、waiting、settled、tool tail。测试
  • 真实 Incremark 覆盖短前缀继续增长与 StrictMode appendability。测试
  • MutationObserver 自动滚动与用户离底暂停有 hook 测试。测试
薄冰与边界
  • 🟠transport 没有显式 caught-up marker 或 cursor activation barrier;isHydrating 仍是 timer heuristic。源码
  • 🟡RunnerManager dispose/crash、startTurn failure、runControl failure 没有随 diff 新增各自的 tool ordering 专项测试。接线源码
  • 🟡CJK 测试证明所覆盖链路的码点完整,没有生产修复;用户看到的原始乱码根因仍 uncertain测试
  • 仓库约定不含 browser E2E/UI snapshots;PR 的 UI 验证主要是 Vitest/Testing Library 与项目 CI。仓库约定 · CI
CI 状态:2026-07-16 查询时,Commit messages、Title & description、verify 三项均通过;这是远端 GitHub Actions 的可验证结果。原始材料:PR Checks
合并前事实边界:当前没有红色必办项;仍需把 200ms/2s hydration heuristic 当作现状,不要把 #150 描述成已经拥有 transport-level replay/live 边界。原始材料

13验收提示

14覆盖声明

本报告以 4afb572...40bb96a 为唯一 diff 范围,全量逐文件阅读 54 个变更文件,没有抽样;daemon、Desktop state/workbench、Desktop renderer/docs 三个并行阅读任务分别覆盖各自子系统,主笔又二次精读了所有旅程经过的入口、reducer、状态派生、adapter、terminal drain 和对应测试。工作区存在用户未提交改动,因此所有 PR after 源码都通过 commit 40bb96a 读取,没有把当前工作区内容混入 PR 事实。原始材料:PR Files

Wiki 背景查询命中并阅读了 [[Eyrie Agent 事件契约与流式恢复模型]][[Eyrie Session UI 优化方案]][[流式Agent-Session-UI实现方案与踩坑]][[incremark useIncremark 引用陷阱与 effect 依赖循环]][[assistant-ui vs incremark 技术决策]];它们只用于建立历史语境,报告里的实现结论仍以 PR 原始文件为准。