BodySense Active Turn State Machine
把一次尚未完成的 Consultation turn 建模为唯一 ActiveTurnState,通过纯 reducer 将 StreamEvent 投影为文本、工具、引用、安全、interaction 等状态,并把副作用从状态转换中分离。
[!info] related notes
- 所属 MOC: bodysense-moc、frontend-engineering-moc
- 相关概念: react-use-reducer、react-context-and-state-management、bodysense-stream-event-trust-boundary
- 易混淆概念: transient projection vs durable server state
- 相关资源: bodysense-consultation-streaming-architecture、durable-sse-recovery-with-after-seq
BodySense Active Turn State Machine
一句话定义
ActiveTurnState 是当前尚未完成的 Consultation turn 的单一流式投影;所有可信 StreamEvent 通过纯 reducer 转换成新的 ActiveTurnState,而网络、缓存、导航等副作用通过 effects 在 reducer 外执行。
Trusted StreamEvent
↓
Pure Reducer
↓
ActiveTurnState + Effects
↓ ↓
Streaming UI External callbacks
为什么 Active Turn 是“投影”,而不是新的 Source of Truth
这一点非常关键。
正在流式生成时,前端需要立即显示:
- text delta;
- tool progress;
- citation;
- knowledge gap;
- red flag;
- pending interaction。
这些信息还没有全部收敛成最终 durable thread/read model,因此前端必须维护一个临时状态。
但它的定位应该是:
ActiveTurnState
= 当前 run event stream 的 transient projection
而不是:
ActiveTurnState
= Consultation 业务事实的最终 owner
最终 durable truth 仍在服务器:
Go Runtime / persistent messages / runtime events / workspace state
所以:
live projection
→ gives immediacy
durable server state
→ gives authority and recovery
如果把 ActiveTurnState 当成长期真值,刷新页面后就会失忆;如果完全不用它,流式 UI 又无法即时响应。
为什么需要一个独立的 Active Turn
流式聊天最容易出现的状态问题不是“不会用 useState”,而是同一件事被多个状态源同时拥有。
例如一次 assistant turn 还没结束时,可能同时出现:
assistant-ui 内部 streaming message
local text state
local tool-calls state
citation state
red-flag state
pending ask_user card
TanStack Query 历史 messages
如果每个 event 各自调用不同 callback 更新不同 store:
text.delta → setText
citation → setCitation
interaction → setModal
phase → queryClient.setQueryData
很快会出现:
- replay 顺序不同导致 UI 不一致;
- 一个 state 重置了,另一个没重置;
- 网络重连后重复 tool card;
- assistant message 已完成,但 interaction 还挂着;
- page refresh 与 live UI 表现不同。
因此当前 turn 应先被视为一个完整状态机,而不是一堆回调。
为什么“一堆 useState”比看起来更危险
例如:
const [text, setText] = useState("")
const [tools, setTools] = useState([])
const [citations, setCitations] = useState([])
const [interaction, setInteraction] = useState(null)
单独看都很合理。
问题在于它们之间存在跨字段 invariant:
status=completed
→ pendingInteraction 必须为空
runId changed
→ old tool/citation state 必须清理
interaction.required
→ status 应变为 interrupted
run.resumed
→ pending interaction 被消费,status 回 streaming
如果这些字段由分散 callback 独立修改,就很容易出现不可能状态。
Reducer 的价值是把跨字段 transition 收口到一个地方。
ActiveTurnState 包含什么
当前 BodySense reducer 中包含类似:
ActiveTurnState
├─ runId
├─ conversationId
├─ assistantMessageId
├─ status
│ ├─ idle
│ ├─ streaming
│ ├─ interrupted
│ ├─ completed
│ └─ failed
├─ text
├─ toolCallsById
├─ citationsByKey
├─ knowledgeGapsByKey
├─ redFlag
├─ pendingInteraction
├─ extractedInfoByBodyPart
├─ finalParts
├─ lastSeqByType
└─ error
注意它不是整个 Consultation 的永久状态。
它只负责:
“当前正在进行的那个 turn,此刻被事件投影成什么样?”
State Invariants 应明确写出来
不要只靠 reducer implementation 隐式表达。
例如可以定义:
AT-01 idle → no active runId
AT-02 interrupted → pendingInteraction != null
AT-03 completed → no pendingInteraction
AT-04 failed → terminal error exists
AT-05 tool result with ID X updates tool X, not creates duplicate normal card
AT-06 stale event from another run must not mutate current run
AT-07 terminal run cannot be reopened by late non-resume event
把这些 invariant 写成测试,状态机会稳定很多。
为什么使用 Map-like keyed state,而不是数组 append
Tool Call、Citation、KnowledgeGap 都可能收到:
- 重放;
- 更新;
- duplicate event;
- live + durable recovery overlap。
如果只:
toolCalls.push(event.payload)
重复事件就会产生重复 UI。
因此使用:
toolCallsById[tool_call_id] = latest value
citationsByKey[key] = citation
knowledgeGapsByKey[gap_id] = gap
更符合 upsert projection。
这种模型体现:
Event log may contain retries/replays
UI projection should converge
Identity 决定 Projection Semantics
不同 event 的去重 identity 不一样:
ToolCall
→ tool_call_id
Citation
→ citation/evidence identity
Interaction
→ interaction_id
Run
→ run_id
不能简单使用:
JSON.stringify(event)
作为业务 identity。
同一个 ToolCall 的 running 和 completed payload 不同,但仍然是同一个工具生命周期。
Reducer 为什么必须 Pure
理想 reducer:
(currentState, event)
→ nextState + declared effects
不应该在 reducer 内:
- fetch;
- toast;
- navigate;
- queryClient.invalidateQueries;
- 写 localStorage;
- 调 callback;
- 读取当前时间作为业务事实。
原因有三个。
1. Replay
同一组 events:
E1, E2, E3
应该能确定性地产生同一个 projection。
2. Unit Test
可以直接:
state0 + event → expected state1
不需要 mock 浏览器环境。
3. Failure Localization
如果 state 错了,可以先查 reducer;如果 side effect 错了,再查 effect runner,不会混成一个大 hook。
Reducer 的数学直觉:Fold over Event Log
可以把最终状态看作:
S_n = reduce(reduce(reduce(S_0, E_1), E_2), ... E_n)
也就是:
State = fold(EventLog)
这解释了为什么:
- reducer 需要 deterministic;
- replay 应共用 reducer;
- wall-clock
Date.now()不应该偷偷改变 state; - event identity/order 很重要。
如果同一事件序列重放两次得到不同 state,就已经违反 projection contract。
State 与 Effects 为什么要同时返回
有些事件不仅更新当前 turn,还需要通知上层 durable/server-state cache。
例如:
conversation.created
→ ActiveTurn remembers conversationId
→ effect: register conversation in query cache
message.persisted
→ effect: reconcile client temp message id with server id
state.phase.changed
→ effect: patch durable thread projection
因此 reducer 返回:
ReductionResult
├─ state
└─ effects[]
Effect runner 再执行:
conversation_created
message_persisted
phase_changed
red_flag
citation_added
interaction_required
message_completed
...
这相当于:
Pure decision
+
Imperative shell
是一个很经典的 functional core / imperative shell 模式。
Effect 也需要 Idempotency
一个常见误区是:
Reducer 已经幂等
→ 整个系统就幂等
不一定。
假设 replay 重复收到:
conversation.created seq=3
Reducer 可能保持同一个 state,但如果每次仍发 effect:
createConversationToast()
queryClient.insertConversation()
analytics.track(...)
副作用仍然重复。
因此 effect 层也需要:
- 根据 event identity 去重;
- effect 本身设计成 upsert/patch;
- 或 reducer 在 duplicate/stale event 上不再发 effect。
这就是为什么 distributed streaming 系统不能只盯“state 是否重复”。
Exactly-once 通常是幻觉,目标更实际地是 At-least-once + Idempotent Projection
网络与 recovery 场景中,事件可能被重复看到。
与其试图证明:
每一条 event 永远只被客户端处理一次
更可靠的是:
事件可能 at-least-once 到达
+
projection/effect 对重复 identity 收敛
所以设计重点是:
stable identity
ordered cursor
idempotent reducer
idempotent effects
而不是“保证绝对零重复”。
Status State Machine
当前 turn 的典型生命周期:
idle
↓ run.started
streaming
├─ message.text.delta → streaming
├─ tool.call/result → streaming
├─ interaction.required
│ ↓
│ interrupted
│ ↓ run.resumed
│ streaming
├─ message.completed / run.completed
│ ↓
│ completed
└─ run.failed / stream.error
↓
failed
这里 interrupted 不是 error。
它表示:
当前 run 正常暂停,等待一个合法的外部输入。
这是 Agent UI 与普通 chat UI 的重要差异。
Transition Table 比散落 switch 更适合审查
可以把关键 transition 写成:
| Current | Event | Next | 关键副作用 |
|---|---|---|---|
| idle | run.started | streaming | bind run identity |
| streaming | text.delta | streaming | append text |
| streaming | interaction.required | interrupted | set pending interaction |
| interrupted | interaction.answered | interrupted | record answer projection |
| interrupted | run.resumed | streaming | clear/consume pending |
| streaming | run.completed | completed | finalize/reconcile |
| any non-terminal | run.failed | failed | record error |
表格能发现非法路径,例如:
completed + text.delta
应该忽略、告警或明确处理,而不是无意 reopen。
Text Delta 为什么是 Append,而 Tool Call 通常是 Upsert
不同 event 有不同 reduction semantics。
Text
message.text.delta("Hel")
message.text.delta("lo")
→ "Hello"
属于 append。
Tool Call
同一个 tool_call_id 可能经历:
running
→ completed
属于 upsert / replace-by-identity。
Citation
相同 citation identity 应去重。
Interaction
required
→ answered
→ cleared on resume/completion
属于小状态机。
因此“所有事件都 array.push()”并不是事件驱动 UI。
真正的 event projection 是:
每类 event 定义自己的 state transition semantics。
Tool Result 先于 Tool Call 怎么办
在 perfect ordered stream 中不应该发生,但 recovery/legacy/projection bug 可能出现。
稳健策略可以是:
tool.result X arrives
no X exists
→ create placeholder X with result
以后 tool.call X 到来:
upsert metadata
或者 protocol 明确将此视为 invalid order。
重点是语义要明确,不要让 UI 因 undefined access 崩溃。
如果 run-level seq 已严格保证顺序,最好由 trust/protocol layer 发现这种 contract violation;placeholder 更适合作为兼容/repair 策略,而非永久掩盖上游错误。
lastSeqByType 与项目现实
理想协议通常希望:
一个 run 内有单调递增 seq
当前 reducer 使用 lastSeqByType 作为额外幂等保护,因为项目历史中存在不同 seq space 混合的 compatibility reality。
因此学习时要区分:
General principle:
identity + ordering + idempotent projection
Current implementation detail:
lastSeqByType
不要把当前 workaround 当成永恒的领域原理。
为什么 Per-Type Seq 不是理想的长期模型
如果:
text seq=5
tool seq=2
interaction seq=1
就很难回答整个 run 的全局因果顺序:
到底 interaction 是在 tool 前还是后?
更强的协议是 run-local canonical sequence:
(run_id, seq)
作为唯一 ordering identity。
如果历史兼容需要多个 seq space,可以暂时维护 adapter,但 north-star 应减少歧义。
Late Event 与 Run Isolation
最危险的 race 之一:
Run R1 completed
Run R2 started
late event from R1 arrives
如果 reducer 只看 type/seq,R1 的 late text/tool 可能污染 R2。
所以每条 event 至少要检查:
event.run_id == activeTurn.runId
或由更上层 router 根据 run identity 分发。
[!important]
seq只有在明确的 run namespace 中才有意义。
seq=10 不是全球唯一坐标,必须和 run_id 绑定。
ActiveTurnState 与 assistant-ui 的边界
assistant-ui 很适合提供:
- message rendering primitives;
- thread runtime shell;
- composer;
- assistant message API。
但 BodySense 当前 turn 含有更多 domain-specific runtime state:
red flag
knowledge gap
extracted health info
pending interaction
phase
citations
tool lifecycle
因此不能只把所有信息压进一串 assistant text。
更合理:
assistant-ui
= generic chat/runtime presentation shell
ActiveTurnState
= BodySense current-run semantic projection
完成后再把稳定的最终消息内容交给历史 message runtime。
UI 应从 State Selector 渲染,而不是从 Event Callback 直接渲染
错误心智:
收到 tool.call
→ 直接 append 一个 ToolCard component
这使 UI 结构和 transport event 耦合。
更合理:
Event
→ reducer
→ ActiveTurnState
→ selector
→ render
这样 replay、refresh、live 都能得到同一种 UI。
Component 负责:
render current projection
而不是:
remember every event ever received
Selector 为什么值得独立
ActiveTurnState 可能很丰富,但每个组件只需要一部分。
例如:
selectStreamingText(state)
selectVisibleToolCalls(state)
selectPendingInteraction(state)
selectSafetyBanner(state)
这样可以:
- 限制组件耦合;
- 减少不必要 rerender;
- 单测 projection semantics;
- 隐藏内部 map/normalization 结构。
状态模型可以演化,而 UI 不需要知道每个内部字段。
React Context 为什么可以拆 State 与 Actions
如果一个 Context 同时暴露:
large state object
+
all callbacks
每个小状态变化可能让所有消费者 rerender。
常见做法:
ActiveTurnStateContext
ActiveTurnActionsContext
或 selector-based store。
关键不是追求某个框架模式,而是:
stable action references
+
consumer only subscribes to needed state
这属于 UI performance/ownership 边界,不是 domain truth 变化。
Active Turn 与 TanStack Query 不要互相抢 ownership
ActiveTurnState
拥有:
current streaming text
current tool status
current pending interaction
current live safety/citation projection
TanStack Query
拥有:
persisted thread
conversation list
durable workspace/read models
在 stream 中可以根据 effects 做 selective patch,但最后:
stream finished
→ invalidate/reconcile durable queries
→ server truth wins
这让“即时体验”和“最终一致”可以同时成立。
为什么不要把 Server State 复制进 Local State 后长期维护
经典 React 反模式:
const { data } = useQuery(...)
const [messages, setMessages] = useState(data)
然后两份数据同时演化。
除非有明确的 transient edit/projection 语义,否则会产生 drift。
ActiveTurn 是合理的 local projection,因为它表示尚未最终持久化收敛的一轮;历史 messages 则不应该长期复制成第二个 truth。
Completion Reconciliation
当 run 完成:
ActiveTurn contains final live projection
但 durable server write 可能刚完成或稍后可见。
推荐流程:
terminal event
→ mark ActiveTurn completed
→ apply final effects
→ refetch/reconcile durable thread/read model
→ server truth confirmed
→ reset ActiveTurn
不要在看到第一个 message.completed 就立刻清空,导致 UI 闪回旧 Query cache。
可以等待合适的 run terminal / persisted-message contract。
为什么完成后要 Reset Active Turn
当 turn 已经完成并持久化:
ActiveTurnState
就不应继续承担历史消息责任。
否则下一 turn 很容易继承:
- 上一条 redFlag;
- 旧 pendingInteraction;
- old tool calls;
- old runId。
所以在 terminal 状态后 reset:
completed / failed / idle
→ resetActiveTurnState()
历史内容由 durable thread/query/runtime 接管。
Reset 也要避免“过早重置”
如果:
run.completed
→ immediately reset
→ durable query still stale
用户可能看到完整回答瞬间消失。
所以 Reset 应是 lifecycle/reconciliation 的一部分,而不是单纯 finally { reset() }。
Live 与 Replay 为什么必须共用 reducer
如果:
Live SSE
→ reducer A
Refresh / Replay
→ projection B
则同一事件历史可能形成两个 UI。
正确目标:
StreamEvent sequence
→ same reducer semantics
→ same projection invariants
所以 ActiveTurn reducer 不只是“React 状态优化”,也是 replay consistency 的业务边界。
Live/Replay Race:Catch-up 期间 Live 又恢复了怎么办
可能出现:
live received seq 1..5
connection dropped
recovery fetch starts after_seq=5
server returns 6..8
meanwhile live reconnect receives 7..10
客户端可能同时看到重复 7/8。
所以必须依赖:
run_id + seq identity
+
idempotent reducer/effects
而不是假设 recovery 和 live 永远严格串行。
这也是 durable streaming 要从“happy path UI”升级成 protocol/state-machine 思维的地方。
不要把 Wall Clock 作为 Reducer 业务决定
例如:
case "tool.call":
return { startedAt: Date.now() }
同一 persisted event replay 两次会得到不同 state。
更好:
- event 带
occurred_at/created_at; - 或 UI-only elapsed time 在 selector/presentation 层计算;
- 不把
Date.now()写成 durable semantic projection。
这样 replay deterministic。
Error State 也要区分 Transport Error 与 Business/Run Error
如果 SSE reader 抛错,但 run 仍在服务器继续:
transport failed
不应该 reducer 直接:
status = failed
直到 durable recovery/explicit run event 证明:
run.failed
这延续了:
Transport Lifetime ≠ Run Lifetime
的原则。
测试思路
Transition tests
run.started → streaming
interaction.required → interrupted
run.resumed → streaming + clear pending
run.completed → completed
Idempotency tests
重复:
tool.call same ID
citation same key
same run seq
不应重复渲染或重复 effects。
Replay equivalence tests
live event sequence
vs
same persisted event sequence
应产生等价 projection。
Isolation tests
新 run 开始时不得残留上一 run state;late R1 event 不得污染 R2。
Ordering tests
seq older than current
→ ignore/no effect
以及 invalid impossible ordering 有明确策略。
Effect tests
重复 event:
state idempotent
AND external effect does not duplicate business-visible side effect
Determinism tests
同一 input state + event:
same output state/effects
不能依赖 wall clock/randomness。
Property-style tests
对某些 keyed entities:
apply(E) == apply(E,E)
或:
same final ordered event set
→ same normalized projection
可以用 property-based testing 捕捉大量边缘组合。
一个完整 race 例子
R1 seq=10 tool.call T7
→ UI shows running
network drops
recovery returns seq=11 tool.result T7
→ UI shows completed
live reconnect late-delivers seq=10 tool.call T7
→ stale seq ignored
R1 completes
→ durable reconciliation
R2 starts
late R1 seq=12 citation
→ run identity mismatch ignored/flagged
如果没有 run/seq/idempotency,至少会出现:
- tool status 回退;
- citation 加到下一轮;
- duplicate effect。
自测题
- ActiveTurnState 为什么是 transient projection,不是 durable truth?
- 为什么多个
useState容易产生跨字段不可能状态? - Reducer pure 对 replay 有什么直接价值?
- 为什么 reducer idempotent 还不代表 effects idempotent?
- At-least-once + idempotent projection 为什么比追求 exactly-once 更现实?
- Tool result/Tool call 为什么通常是 keyed upsert 而不是 append?
seq为什么必须和run_id一起理解?- UI 为什么应该 render selector/state,而不是 event callback 直接 append component?
- ActiveTurn 和 TanStack Query 的 ownership 怎么分?
- 为什么 completion 后 reset 之前要做 durable reconciliation?
- Live/replay race 为什么会重复事件?
- Reducer 为什么不应该用
Date.now()生成业务事实?
最终心智模型
ActiveTurnState 不是“为了少写几个 useState”,而是把当前 Agent run 的 UI 语义从散乱 callback 提升成一个可重放、可测试、可收敛的事件投影状态机。
更完整地说:
Untrusted transport
→ validated StreamEvent
→ pure/idempotent projection
→ transient ActiveTurn
→ durable reconciliation
→ reset
这条链才是 production streaming UI 的核心,而不是“能把 token 一字一字显示出来”。