PR #150:把 Session 从“最后一条消息在动”改成可重放的播放系统
buffin-ai/buffin · 4afb572...40bb96a · 2026-07-16 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。全部事实均链接到 PR #150、对应 commit/blob 或 CI 原始材料;无法由 PR 证明的原因标为 uncertain。
1TL;DR
这个 PR 同时补齐了两端:daemon 让 message/tool 增量在 run terminal 前稳定落库,Desktop 把这些事件折叠成可重放的工具输出、control receipt 和消息 span;在事实投影之上,新的 presentation 层再决定当前表面应当 static、revealing 还是 frozen。因此,历史回放不会重演动画,审批暂停不会吞掉 reveal 进度,隐藏 tab 继续接收事件但不耗费动画,终止中的工具仍保留部分输出。原始材料:PR 描述与完整 diff
这不是完整的“新 Session 引擎”:共享 API 事件联合、显式 caught-up marker、cursor activation barrier、五个独立 projector 和 Streamdown 对照 adapter 都没有进入 #150;当前同步边界仍来自既有 hydration 计时启发式。原始材料:session-runtime-store.ts · 前四个提交的随附走读
2变更地图
按 numstat 计算,测试文件承载 2,007 行变更,非测试代码 1,289 行;测试占比是 60.9%。PR 没有 rename,也没有数据库、migration、依赖或 API 包变更。原始材料:PR Files
| 子系统 | 设计重心(要细读) | 可放心略过 | 来源 |
|---|---|---|---|
| daemon / sink | pendingToolFlushes、flushTerminalDeltas、retireRun 定义 durable terminal 顺序。 | toolkit 的 barrel re-export。 | SessionSink |
| desktop / projection | ToolOutputChunk、control receipt、message span closed、streamingClosed。 | 三份 locale 只新增同一个“输出已截断”文案。 | session-store |
| desktop / presentation | SessionDomain 与 ThreadPresentation 把 run、同步、可见性、reduced motion 合成展示策略。 | 共享 padding 常量是密度接线,不改变状态语义。 | thread-presentation |
| desktop / renderer | IncremarkAdapter、active part 路由、ToolOutput、ControlReceipt、MutationObserver 自动滚动。 | Composer 的 padding、单行窄布局和 surface class 属于视觉收口。 | Incremark adapter |
3架构一图流
以前 · 事实与动画挤在组件布尔值里
现在 · 先投影事实,再派生播放
结构变化的关键不是换 Markdown 库,而是把“事件事实”“Session 领域状态”“某个挂载表面的播放方式”拆成三层。原始材料:SessionContent.tsx · session-domain.ts
4数据与状态先行
4.1工具输出从字符串变成带身份的片段
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领域状态与展示状态分开
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
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.tsxcontrol.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
tool-delta-coalescer.ts→durable barrier
session-sink.ts→event log / WS replay→projection
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
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。原始材料:并发顺序测试
A.2Desktop reducer 保留流式事实,终态输出仍有最高权威
tool.delta 先找到同 toolCallId 的 part,再按 event id 去重;带 sequence 时仅在同一 stream 内排序。tool.completed 到达后,reducer 删除 streaming 字段并写入 authoritative result。原始材料: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 output | session-sink.ts:看 pendingToolFlushes / waitForToolFlushes;tool-delta-coalescer.ts:看 retireRun。 |
| interrupt 后 partial output 消失 | service.ts:看 flushLiveTerminalDeltas;session-store.ts:看 handleRunCompleted 的 streamingClosed。 |
| stdout 顺序错或 replay 重复 | session-store.ts:看 insertToolOutputChunk 的 eventId 与 per-stream seq 规则。 |
| 终态仍显示 partial,而非 final output | handleToolCompleted:确认 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
use-session-store→domain
session-domain.ts→visibility + reduced motion→presentation
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
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
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.tsx 与 content-visibility.tsx:确认 surface/window visible;再看 derivePlaybackMode。 |
| 旧审批卡让 tab 一直显示 attention | session-domain.ts:确认 pending 只从 active running assistant message 读取。 |
| tool 运行时前一段文字仍继续 reveal | findActiveNarrativePartIndex:tool 是否已成为 message 的最后一个 part。 |
| 同一 Session 有两条回复同时显示 spinner | SessionThread.tsx:只有 activeMessageId 可以得到 running indicator。 |
8旅程 C:playback 如何驱动 Incremark 与滚动
MarkdownPart 现在只接收完整权威文本、playback 和 finished;所有 parser/typewriter 命令收进 IncremarkAdapter。这让上层不再暴露 Incremark 的 append/reset/finalize 生命周期。原始材料:MarkdownPart.tsx
playback + finished→MarkdownPart
产品接口→IncremarkAdapter
parser / typewriter→scroll observer
use-auto-scroll-bottom.ts
C.1revealing、frozen、static 分别对应 resume、pause、skip
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 回归测试
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 不进 DOM | incremark-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.delta 与 control.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;具体合并原因 uncertain。PR files |
10心智模型补丁
11新词表
| daemon ordering | |
|---|---|
terminal drain | 在 run terminal 前排空所有必须先落库的 message/tool 增量。来源 |
retired run | 已经 terminalize、在当前 sink 生命周期内拒绝迟到工具输出的 run。来源 |
timer-dispatched write | timer 已取走 buffer 并调用 emit,但 durable append 尚未结束的异步写入。来源 |
| projection | |
ToolOutputChunk | 带 envelope id、可选 stream/seq/truncated 的一段运行中工具输出。来源 |
streamingClosed | run 已结束但工具没发 tool.completed;保留 partial output,同时不再显示 running。来源 |
control receipt | 由 control.completed 投影出的 replay-safe 终态命令回执。来源 |
| presentation | |
SessionDomain | 从 message projection 与 isRunning 得出的当前执行阶段,不包含动画。来源 |
ThreadPresentation | 某个挂载表面当前的 active message、active part、playback 与 enter animation 快照。来源 |
frozen | parser 可继续接收内容,但 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
13验收提示
- streaming output 在 tool.completed 后消失是预期行为:权威 result 替换运行中片段,不是丢数据。来源
stopped不等于 failed:它表示 run 已结束而工具没给 terminal;partial output 仍显示,但 spinner 停止。来源- 隐藏 tab 仍在接收事件:#150 抑制的是动画,不是 projection 或 subscription。挂载来源 · 订阅来源
- control receipt 可以独立成 message:没有 open run 的 control.completed 仍需在 timeline 可见。来源
- CJK negative control 故意产生 U+FFFD:它证明 fixture 能抓住 naive per-chunk decode,不表示生产链测试失败。来源
- Reasoning 的紫色状态边线被关闭,但语义 blockquote 左线保留。来源
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 原始文件为准。