Agent의 Server 측에서는 실행 과정을 Event로 표현하기에 적합합니다. Turn 시작, 모델 출력, Tool Call, Tool Result, Approval, Compaction, Run 상태 변화는 모두 순서대로 도착하는 사실이 될 수 있습니다.
브라우저에 최종적으로 필요한 것은 Event 목록이 아닙니다.
한 채팅 인터페이스에 필요한 것은 다음과 같습니다:
Message Row
Tool Card
Approval Panel
Plan Block
SubAgent Node
Running Indicator
Composer State
따라서 Web Runtime 중간에는 반드시 한 번의 상태 변환이 일어납니다:
Event
↓
Projection
↓
View State
↓
UI
이 Projection을 어디에 배치하느냐에 따라 시스템이 Replay, Streaming, Reconnect, 플러그인 확장을 제대로 처리할 수 있는지가 결정됩니다.
React에서 Event를 직접 처리할 때의 문제
가장 쉽게 구현할 수 있는 구조는 컴포넌트가 Event Stream을 구독하도록 하는 것입니다:
짧은 기간에는 이 코드만으로 충분합니다. 하지만 히스토리 복원을 추가하면 두 번째 경로가 생깁니다:
이 시점부터 시스템에는 두 개의 상태 구성 로직이 생깁니다:
History Replay
→ buildMessages()
Live Event
→ reduce()
두 경로의 규칙이 완전히 같지 않으면, 동일한 Session에서도 ‘처음 열 때’와 ‘계속 온라인 상태일 때’ UI가 달라집니다.
Tool Call은 대표적인 예입니다. 히스토리에는 이미 다음 두 항목이 함께 있을 수 있습니다:
tool/call
...
tool/result
실시간 상태는 다음 과정을 거칩니다:
tool/call
→ running card
→ tool/result
→ completed card
히스토리 생성 함수가 최종 결과를 직접 읽고 실시간 reducer가 중간 상태를 관리한다면, 두 코드 경로에는 쉽게 서로 다른 규칙이 생겨 조금씩 벌어질 수 있습니다.
따라서 더 안정적인 원칙은 다음과 같습니다:
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[] 하나로 압축하기 쉽습니다. 그러면 두 번째 단원에서 다룬 문제가 다시 생깁니다. 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를 지원해야 합니다.
- 업데이트 빈도가 rerender가 필요한 빈도보다 훨씬 높을 수 있습니다.
따라서 현재 DSH는 브라우저 측 데이터 객체 계층을 React-free로 정의합니다:
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
세 상태는 출처가 서로 다르므로, 하나의 전역 Store로 통일할 필요가 없습니다.
Session은 연속된 Event Window를 유지해야 합니다
현재 DSH Client Session은 내부적으로 다음을 유지합니다:
목표는 Server의 전체 Session을 저장하는 것이 아니라, 브라우저에 현재 적재된 하나의 연속된 Event Window를 유지하는 것입니다.[4]
처음 열 때는 다음과 같이 처리합니다:
Session.open()
↓
history(maxMessages = 50)
↓
installWindow()
↓
Conversation.replaceWindow()
히스토리를 읽는 동안 실시간 Event가 이미 도착했다면, 해당 Event는 먼저 liveBuffer에 들어갑니다. 히스토리 Window가 설치된 후 seq 순서로 append합니다.[4]
여기서 핵심은 Buffer 자체가 아니라 연속성 조건입니다.
히스토리 끝부분이 다음과 같다고 가정해 봅시다:
seq = 120
그 뒤 Client가 다음을 받습니다:
seq = 123
바로 append하면 121과 122는 영구히 사라집니다. DSH는 현재 이런 구멍이 있는 Window를 허용하지 않고, 123을 Buffer에 넣은 후 tail page repull을 실행합니다:
120
↓
123 arrives
↓
gap detected
↓
123 → liveBuffer
↓
reload tail history
↓
121, 122, 123...
↓
installWindow + dedup
따라서 seq는 정렬, 중복 제거, Gap Detection을 동시에 담당합니다.[4]
Reconnect는 본질적으로 Baseline을 다시-establish하는 과정입니다
네트워크가 재연결된 후에는 클라이언트가 연결이 끊겨 있는 동안 아무 변화도 없었다고 가정할 수 없습니다.
DSH의 SessionManager.handleConnected()는 다음을 수행합니다:
refresh session list
refresh relevant subagent catalogs
resync every resident Session
Session.resync()은 generation을 증가시키고 기존 Window를 정리한 다음 다시 open()합니다.[5][6]
openGeneration은 전형적인 동시성 문제를 해결합니다:
기존 요청이 반환된 뒤에도 상태를 쓸 수 있다면, 예전 연결에서 얻은 결과가 새 generation을 덮어쓸 수 있습니다.
따라서 doOpen(generation)은 모든 await 후에 다음을 확인합니다:
이러한 generation token은 Client Runtime에서 흔히 쓰이는 효과적인 경쟁 조건 격리 수단입니다.[4]
Projection에도 증분 경로가 필요합니다
Event를 받을 때마다 전체 Session Window를 다시 스캔한다면 다음과 같이 처리하게 됩니다:
new event
↓
scan event 0..N
↓
rebuild every node
긴 세션에서는 뚜렷한 비용이 생깁니다.
현재 DSH의 ConversationNodeAssembler는 세 경로를 동시에 지원합니다:
replaceWindow()
用于 open / resync / gap repair
prepend()
用于加载更早历史
append()
用于实时尾部 Event
append()는 기존 Context를 다시 스캔하지 않고 현재 Event만 처리해 해당 Context와 View Builder를 갱신합니다.[7]
따라서 Client Projection의 계산 모델을 다음처럼 나타낼 수 있습니다:
低频路径:完整重建
replaceWindow(history)
高频路径:增量折叠
append(event)
두 경로가 동등한 비즈니스 결과를 내야 하지만, 성능 전략은 서로 달라도 됩니다.
ConversationNodeDefinition은 비즈니스 Fold를 Runtime에서 분리합니다
Runtime 내부에 거대한 switch를 작성하면 다음과 같이 됩니다:
UI Feature를 하나 추가할 때마다 핵심 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 target은 Location, Anchor Sequence, Visibility를 추가로 전달합니다.[8]
이 계층이 중요한 이유는 비즈니스 의미와 React 컴포넌트를 분리할 수 있기 때문입니다:
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를 동기 render하도록 강제하면, Projection 계층이 올바르더라도 UI 성능이 크게 저하될 수 있습니다.
현재 DSH는 ConversationNodeDefinition에 publication()을 추가했으며, 그 결과는 다음과 같을 수 있습니다:
none
animation-frame
immediate
Assembler는 현재 transaction이 요구하는 가장 높은 publication cadence를 선택합니다.[7][8]
이렇게 하면 서로 다른 두 빈도가 만들어집니다:
Event Fold Frequency
可能每个 chunk 都执行
React Publication Frequency
可以按 animation frame 合并
따라서 Client Runtime은 자주 간과되는 역할을 맡게 됩니다. 비즈니스 상태는 즉시 계산해야 하지만, UI 알림은 배치 처리할 수 있습니다.
이 둘을 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
비즈니스 컴포넌트는 Socket을 직접 구독하지도, Projection을 직접 실행하지도 않습니다.
더 완전한 데이터 흐름
이번 글에서 다룬 여러 계층을 연결하면 다음 흐름을 얻을 수 있습니다:
이 경로는 다음 모든 상황에 적용됩니다:
首次打开
历史分页
实时 Streaming
网络重连
Gap Repair
插件重新注册后的 Projection rebuild
설계 판단
Agent UI의 안정적인 상태 모델은 두 가지 조건을 만족해야 합니다.
첫째, Replay와 Live는 같은 Projection 규칙을 사용해야 합니다. 그렇지 않으면 복원된 UI와 온라인 런타임 상태는 결국 서로 어긋납니다.
둘째, Projection은 컴포넌트 트리가 아니라 Client Runtime에 속해야 합니다. React는 Projection 결과를 관찰하되, 사실 원본과 복구 알고리즘을 관리할 책임은 없습니다.
따라서 Agent Web UI에 더 적합한 관계는 다음과 같습니다:
Event 是事实变化
Projection 是业务解释
View State 是 UI 输入
React 是渲染器
다음 글에서는 마지막 문제를 이어서 다룹니다. ConversationViewNode를 얻은 뒤 어떤 컴포넌트가 이를 렌더링할지 결정하는 문제, 그리고 Tool, Plan, SubAgent, Workflow 같은 Feature를 중앙 ChatView를 수정하지 않고 UI에 통합하는 방법을 다룹니다.
참고 자료
[1] DeepSeek Harness Session 아키텍처: 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 규칙: 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 계약: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/contract/conversation.ts