BodySense Active Turn State Machine

把一次尚未完成的 Consultation turn 建模为唯一 ActiveTurnState,通过纯 reducer 将 StreamEvent 投影为文本、工具、引用、安全、interaction 等状态,并把副作用从状态转换中分离。

#type / synthesis #status / growing #tech / dev / frontend #tech / architecture #tech / ai #resource / bodysense #resource / react #resource / typescript

[!info] related notes

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 的 runningcompleted 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 写成:

CurrentEventNext关键副作用
idlerun.startedstreamingbind run identity
streamingtext.deltastreamingappend text
streaminginteraction.requiredinterruptedset pending interaction
interruptedinteraction.answeredinterruptedrecord answer projection
interruptedrun.resumedstreamingclear/consume pending
streamingrun.completedcompletedfinalize/reconcile
any non-terminalrun.failedfailedrecord 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。

自测题

  1. ActiveTurnState 为什么是 transient projection,不是 durable truth?
  2. 为什么多个 useState 容易产生跨字段不可能状态?
  3. Reducer pure 对 replay 有什么直接价值?
  4. 为什么 reducer idempotent 还不代表 effects idempotent?
  5. At-least-once + idempotent projection 为什么比追求 exactly-once 更现实?
  6. Tool result/Tool call 为什么通常是 keyed upsert 而不是 append?
  7. seq 为什么必须和 run_id 一起理解?
  8. UI 为什么应该 render selector/state,而不是 event callback 直接 append component?
  9. ActiveTurn 和 TanStack Query 的 ownership 怎么分?
  10. 为什么 completion 后 reset 之前要做 durable reconciliation?
  11. Live/replay race 为什么会重复事件?
  12. 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 一字一字显示出来”。

创建于 2026/8/22 更新于 2026/8/23