AgentのServer側は、実行過程をEventで表すのに向いている。Turnの開始、モデル出力、Tool Call、Tool Result、Approval、Compaction、Runの状態変化は、いずれも順番に到着する事実として表せる。
ブラウザが最終的に必要とするものは、Eventのリストではない。
チャットUIに必要なのは次の要素だ。
Message Row
Tool Card
Approval Panel
Plan Block
SubAgent Node
Running Indicator
Composer State
そのため、Web Runtimeの中間では必ず1回状態変換が生じる。
Event
↓
Projection
↓
View State
↓
UI
Projectionをどこに配置するかは、システムがReplay、Streaming、Reconnect、プラグイン拡張を正しく扱えるかを直接左右する。
React内でEventを直接処理する問題
最も実装しやすいのは、コンポーネントにEvent Streamを購読させる構成だ。
短期間なら、このコードで十分だ。しかし、履歴の復元を追加すると、第二の経路が生まれる。
この時点で、状態を組み立てるロジックは2系統ある。
History Replay
→ buildMessages()
Live Event
→ reduce()
2つの経路でルールが完全に一致していなければ、同じSessionでも「初めて開いたとき」と「オンラインを続けているとき」でUIが違ってしまう。
Tool Callは典型例だ。履歴には、次のEventがすでに同時に存在している可能性がある。
tool/call
...
tool/result
リアルタイムの状態は次の過程を経る。
tool/call
→ running card
→ tool/result
→ completed card
履歴構築関数が最終結果を直接読み、リアルタイム側のreducerが中間状態を保持しているなら、2つのコードは次第に異なるルールを持ち始める。
そのため、より安定した原則は次のとおりだ。
ReplayとLive Appendは同じProjection Engineを使う。
Projectionは再実行可能な状態の畳み込み
最小限のProjectionは次のように書ける。
履歴の復元は次の形になる。
initialState
↓ event 0
state 1
↓ event 1
state 2
↓ event 2
...
↓ event N
View State
リアルタイム処理は、その状態からそのまま続くだけだ。
View State at N
↓ event N+1
View State at N+1
この結果、システムはとても重要な不変条件を得る。
fold(history + live)
=
fold(history) continued with live tail
この等価関係により、Replay、Reconnect、オンラインのStreamingは同じセマンティクスを共有できる。
Messageだけが唯一のProjectionではない
Agent UIでは、すべてをmessages[]に押し込めやすい。そうすると、第2回で論じた問題が再発する。Agentの実行には、Messageとして表すのに適さない状態が大量に含まれる。
たとえば次のEventがある。
turn/start
step/start
assistant/chunk
tool/call
tool/result
approval/requested
turn/end
これらのEventから複数のProjectionを同時に導出できる。
ServerとClientで異なるProjectionを持つことはできる。ただし、どちらも明確な事実源と一貫したシーケンス境界を基点にしなければならない。
DeepSeek HarnessではServer Sessionの段階でこの構造がすでに現れている。SessionEvent Logが事実源であり、deriveMessages()はモデルから見える履歴だけを投影する。Browser側にはさらにConversation Projectionがあり、EventをUIに適したNodeへ変換する。[1][2]
なぜClient Runtimeが必要なのか
Reactコンポーネントは、コンポーネントのライフサイクルや局所的なインタラクション状態を管理するには適している。しかし、ネットワーク再接続、Event Window、Gap Repair、業務Projectionの所有権を担うのは適さない。
これらの状態には、いくつかの特徴がある。
- ページ内の複数のコンポーネントで共有する必要がある。
- 状態更新のソースはReactのイベントだけでなく、ネットワークや履歴の読み取りにもある。
- UIがアンマウントされても、SessionはEventを受け取り続けなければならない。
- 状態は完全なReplayをサポートしなければならない。
- 更新頻度が、再レンダリングを必要とする頻度を著しく超える可能性がある。
そのためDSHは現在、ブラウザ側のデータオブジェクト層をReactに依存しない形で定義している。
ConnectionController
↓
SessionManager
↓
Session
↓
Conversation Assembler
Reactはsubscribe() / getSnapshot()を通じてこの層を観察するだけにしている。[3]
これは、従来のフロントエンドで「状態をStoreに入れる」という考え方とも異なる。DSHの制約文書は、Session、Frame、Connectionなどの業務状態をオブジェクト層に残すよう明示している。Slot Storeが担うのは、selection、draft、panel widthなど、共有されるUI状態だけだ。[3]
この境界は残す価値がある。
Business Runtime State
→ Client Runtime object
Shared UI Interaction State
→ UI Store
Component-local State
→ React state
3種類の状態は由来が異なるので、1つのグローバルStoreへまとめる必要はない。
Sessionは連続したEvent Windowを維持する必要がある
現在のDSH Client Sessionは内部で次を保持している。
その目的はServer上のSession全体を保存することではなく、ブラウザに現在ロード済みの連続したウィンドウを維持することだ。[4]
初回オープン時は次のようになる。
Session.open()
↓
history(maxMessages = 50)
↓
installWindow()
↓
Conversation.replaceWindow()
履歴の読み取り中にリアルタイムEventが到着した場合、それらはまずliveBufferに入る。履歴ウィンドウのインストールが完了してから、seq順に追加する。[4]
ここでの要点はBufferそのものではなく、連続性の要件だ。
履歴の末尾が次のだとする。
seq = 120
その後、Clientは次を受け取る。
seq = 123
直接appendすれば、121と122は恒久的に失われる。DSHは現在、このような穴の空いたウィンドウを受け入れない。代わりに123をBufferに入れ、末尾ページの再取得をトリガーする。
120
↓
123 arrives
↓
gap detected
↓
123 → liveBuffer
↓
reload tail history
↓
121, 122, 123...
↓
installWindow + dedup
そのため、seqには並べ替え、重複排除、Gap Detectionという3つの役割がある。[4]
Reconnectは本来、Baselineの再構築である
ネットワークが再接続された後、クライアントは切断中に何も変化がなかったと仮定してはいけない。
DSHのSessionManager.handleConnected()は次を実行する。
refresh session list
refresh relevant subagent catalogs
resync every resident Session
Session.resync()はgenerationを増やし、古いウィンドウを破棄してopen()をやり直す。[5][6]
generationは典型的な並行性の問題に対応する。
旧リクエストが返ってきた後も状態を書き込めたら、古い接続から取得した結果が新しいgenerationを上書きしてしまう。
そのためdoOpen(generation)は、各awaitの後に次を確認する。
このgeneration tokenは、Client Runtimeでよく使う、競合を分離する有効な手法だ。[4]
Projectionには増分経路も必要
Eventを受け取るたびにSession Window全体を再スキャンすると、次のようになる。
new event
↓
scan event 0..N
↓
rebuild every node
長いSessionでは、明白なコストが発生する。
DSHのConversationNodeAssemblerは現在、3つの経路に対応している。
replaceWindow()
用于 open / resync / gap repair
prepend()
用于加载更早历史
append()
用于实时尾部 Event
append()は既存のContextを再スキャンせず、現在のEventだけを処理して、対応するContextとView Builderを更新する。[7]
したがって、Client Projectionの計算モデルは次のように表せる。
低频路径:完整重建
replaceWindow(history)
高频路径:增量折叠
append(event)
2つの経路は同等の業務結果へ到達しなければならない。ただし、性能面のアプローチは異なっていてもよい。
ConversationNodeDefinitionで業務の畳み込みをRuntimeから分離する
Runtime内に巨大なswitchを書いておくと、次のようになる。
UI Featureを1つ追加するたびに、中核Projection Engineを変更することになる。
DSHは現在、ConversationNodeDefinition<State>を使って、この部分の業務ルールを登録する形で実装している。[8]
Definitionは主に次を提供する。
これは、独立した「Event → State → ViewNode」の状態マシンを表す。
たとえばTool Callの概念モデルは次のようになる。
Runtimeが担当するものは次のとおりだ。
Event 顺序
Context identity
Turn / Step Location
调用 Definition
缓存 State
发布 View Snapshot
Feature Pluginが担当するものは次のとおりだ。
哪些 Event 属于自己
如何更新业务 State
最终产生什么 View Node
これにより、Projection自体にもプラグインの境界を持たせられる。
なぜView Nodeを生成してからReactへ渡すのか
ConversationViewNodeは、Reactに入る直前の最後の業務表現だ。
Chatのターゲットは、さらにLocation、Anchor Sequence、Visibilityを持つ。[8]
この層が重要なのは、業務セマンティクスとReact Componentを分離できるからだ。
SessionEvent
↓
Business State
↓
ConversationViewNode
↓
UI Renderer
↓
React Component
そのため、同じProjection EngineをReactなしでテストできる。
業務Featureも、まず自分が正しいNodeを生成できるかを判断できる。
ToolCallNode {
id,
status,
call,
result,
subCalls
}
その後、このNodeの表示方法を単独でテストできる。
Streaming FoldとReact Publicationの周期を揃える必要はない
LLMのStreamingは大量のChunkを生成する。ChunkごとにReact Tree全体の同期レンダリングを求めると、Projection層が正しくてもUI性能が影響を受ける。
DSHは現在ConversationNodeDefinitionにpublication()を追加しており、その結果は次のいずれかになる。
none
animation-frame
immediate
Assemblerは、現在のtransactionが要求するpublication cadenceのうち、最も高いものを使う。[7][8]
これによって、次の2つの異なる頻度が生まれる。
Event Fold Frequency
可能每个 chunk 都执行
React Publication Frequency
可以按 animation frame 合并
Client Runtimeは、しばしば見過ごされがちな役割を担う。業務状態は迅速に計算する必要があるが、UIへの通知はまとめて処理してよい。
この2つをReactのsetState()に直接結び付けると、独立に制御しにくくなる。
ObservableはRuntimeとReactの間の狭いインターフェース
Client Runtimeが完全な状態を持っている場合、ReactはEvent Stream、History API、Reconnectの仕組みを知る必要はない。
必要なのは次のインターフェースだけだ。
React Binding層がuseSyncExternalStore()を利用する。[3]
そのため依存関係は片方向のままになる。
Connection / Session / Projection
│
│ Observable Snapshot
▼
React Binding
│
▼
Component
業務ComponentはSocketを直接購読することも、Projectionを直接実行することもない。
より完全なデータフロー
ここまでで見てきた各層をつなげると、データフローは次のようになる。
このチェーンは次のすべてに適用できる。
首次打开
历史分页
实时 Streaming
网络重连
Gap Repair
插件重新注册后的 Projection rebuild
設計上の判断
Agent UIの安定した状態モデルは、2つの条件を満たす必要がある。
第1に、ReplayとLiveは同じProjectionルールを使う。さもないと、復元後のUIとオンライン実行時の状態は迟早ずれる。
第2に、ProjectionはコンポーネントツリーではなくClient Runtimeに属する。ReactはProjectionの結果を観察するだけで、事実源や復元アルゴリズムを管理すべきではない。
したがって、Agent Web UIには次の関係が最も適している。
Event 是事实变化
Projection 是业务解释
View State 是 UI 输入
React 是渲染器
次回は最後の問題を扱う。ConversationViewNodeまで得られたあと、どのComponentがそれをレンダリングするか。またTool、Plan、SubAgent、WorkflowなどのFeatureを、中央のChatViewを変更せずにUIへ追加するにはどうすればいいか。
参考資料
[1] DeepSeek Harness Session Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md
[2] DeepSeek Harness Session Surface: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/surface.ts
[3] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[4] DeepSeek Harness Client Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/session.ts
[5] DeepSeek Harness ConnectionController: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/src/client/connection.ts
[6] DeepSeek Harness SessionManager: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/manager.ts
[7] DeepSeek Harness ConversationNodeAssembler: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/conversation-assembler.ts
[8] DeepSeek Harness Conversation contracts: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/contract/conversation.ts