앞의 세 편에서는 각각 세 가지 경계를 세웠습니다. 서버 측의 Session과 Run은 네트워크 연결에 속하지 않습니다. 클라이언트 Runtime이 Event Window, 복구, Projection을 담당합니다. React나 다른 UI 계층은 이미 형성된 View State만 소비하고, Slot으로 Feature Renderer를 조합합니다.
이번 편에서는 최소 구현으로 이들을 연결합니다.
이 데모는 실제 LLM을 구현하지 않으며 React, Redis, 데이터베이스도引入하지 않습니다. 목표는 몇 가지 구조적 판단을 검증하는 것입니다.
이 다섯 가지 조건이 성립하면, 이후에 실제 모델, 데이터베이스, React, WebSocket으로 교체하더라도 핵심적인 소유 관계는 변하지 않습니다.
Demo의 구조
디렉터리는 의도적으로 네 계층으로 나뉘어 있습니다.
의존 방향은 단방향으로 유지됩니다.
Presentation 계층은 Event가 어떻게 복구되는지 알지 못하고, Server도 Tool Card가 어떻게 표시되는지 알지 못합니다.
Server는 연결보다 먼저 Session을 소유한다
Server는 각 Session에 대해 다음을 저장합니다.
여기서 events[]가 이 데모의 Canonical State입니다. 실제 시스템에서는 이를 데이터베이스 Event Log로 대체할 수 있습니다.
Event를 추가하는 순서는 다음과 같습니다.
즉,
append durable/canonical fact
↓
publish transport frame
이 순서는 매우 중요합니다.
만약 SSE에 먼저 데이터를 쓴 뒤 비동기적으로 영속화한다면,
Client가 seq=12를 보게 됨
↓
Server crash
↓
12는 영속화되지 않음
↓
재연결 후 Server는 11까지만 복구할 수 있음
Client가 이미 Server가 재구성할 수 없는 사실을 관찰한 상태가 됩니다.
프로덕션 구현에서는 보통 데이터베이스 트랜잭션, Outbox, 또는 durable stream으로 더 강한 보장을 만들어야 합니다. 이 데모는 메모리 Event Log만 사용해 "사실의 커밋이 게시보다 먼저"라는 구조를 검증합니다.
Run과 SSE는 완전히 분리된다
POST /prompt는 새 작업을 접수할 책임만집니다.
POST /prompt
↓
create ActiveRun
↓
202 Accepted
실제 실행은 독립적인 runAgent()가 계속 진행합니다.
이 함수는 순서대로 다음을 생성합니다.
run.started
message.user
message.assistant.started
message.assistant.delta
tool.call
tool.result
message.assistant.delta
message.assistant.completed
run.completed
SSE subscriber가 존재하는지는 Run 상태 판정에 참여하지 않습니다.
따라서:
연결이 끊어져도 run.cancel()은 호출되지 않습니다.
오직 명시적으로
POST /cancel
을 호출해야 ActiveRun의 cancellation state가 바뀝니다.
이것은 4번째 단위 첫 번째 편에서 다룬 핵심 경계를 검증합니다. Connection Abort와 Execution Cancel은 서로 다른 제어 채널입니다.
SSE는 복구 좌표로 seq를 사용한다
Event의 구조는 다음과 같습니다.
seq는 하나의 Session 안에서 연속으로 증가합니다.
SSE Endpoint는 다음을 받습니다.
GET /api/sessions/:id/events?after=N
그리고 먼저 다음을 전송합니다.
all events where seq > N
그 후 연결을 유지하며 live event를 받습니다.
SSE frame에는 동시에 다음이 기록됩니다.
id: <seq>
event: session-event
data: {...}
따라서 seq는 동시에 다음 역할을 겸할 수 있습니다.
Event identity
Ordering coordinate
Replay checkpoint
Dedup key
Gap detection coordinate
실제 시스템이 하나의 필드에 모든 책임을 맡기는 것은 아닐 수 있지만, 연속성을 증명할 수 있는 좌표는 반드시 존재해야 합니다.
Replay와 Live Attach 사이에 Gap이 생겨서는 안 된다
그럴듯해 보이지만 문제가 있는 구현은 다음과 같습니다.
1. query history > N
2. history response returns
3. subscribe live stream
만약 Event가 2번과 3번 단계 사이에 생성된다면,
history ends at 20
21 produced here
live subscribe starts at 22
21은 영구히 유실됩니다.
이 데모의 SSE Endpoint는 동일한 Node Event Loop turn 안에서 다음을 실행합니다.
read current tail
→ write backlog
→ register subscriber
중간에 await가 없기 때문에, 이 짧은 코드 구간이 이 데모에 필요한 replay/attach 원자 경계를 만들어 줍니다.
프로덕션 시스템이 데이터베이스, 메시지 시스템, 멀티 인스턴스 배포를 가로지르는다면 더 정식인 checkpoint나 durable subscription 메커니즘이 필요합니다.
테스트는 의도적으로 한 번 연결을 끊는다
테스트 코드는 먼저 SSE를建立한 뒤 Prompt를 제출합니다.
앞의 네 개 Event를 받은 시점에 직접 Abort합니다.
seq 0 run.started
seq 1 message.user
seq 2 assistant.started
seq 3 assistant.delta
↓
disconnect
이 시점에 저장합니다.
checkpoint = 3
테스트는 클라이언트 연결이 없는 상태에서도 Server Run이 계속 실행되는지 기다립니다.
그 후 다시 연결합니다.
GET /events?after=3
실제 실행 결과는 다음과 같습니다.
checkpoint: 3
replayed tail: 4,5,6,7,8
final seq: 8
projection equality: ok
tool slot dispatch: ok
run status: completed
따라서 복구 경로는 다음과 같습니다.
Client observed 0..3
↓
disconnect
↓
Server produced 4..8
↓
reconnect(after=3)
↓
receive 4..8
두 구간을 합친 뒤 다시 확인합니다.
다음과 같아야 합니다.
Projection은 Transport를 이해하지 않는다
ProjectionEngine에는 핵심 진입점이 하나뿐입니다.
Event가 어디에서 왔는지 전혀 알지 못합니다.
history query
SSE
WebSocket
local fixture
replay test
유일한 요구 사항은 seq가 연속이라는 것입니다.
예를 들어 Assistant Streaming은 다음과 같습니다.
Tool은 다음과 같습니다.
tool.call
↓
ToolNode(status=running)
tool.result
↓
ToolNode(status=completed)
최종 Snapshot에는 UI에 필요한 것만 담깁니다.
이것이 Client Runtime과 Presentation 사이의 프로토콜입니다.
중요한 테스트: 전체 Replay와 분할 Fold는 같아야 한다
테스트는 두 종류의 Projection을 만듭니다.
첫 번째는 모든 Event를 한 번에 읽습니다.
두 번째는 실제 단절 과정을 모사합니다.
마지막으로 단언합니다.
이 테스트는 "최종 페이지가 표시된다"는 것보다 더 중요합니다.
다음 사실을 검증하기 때문입니다.
Projection(history + tail)
=
Projection(history) continued with tail
앞으로 Plan, Approval, SubAgent Node가 추가되더라도 이 성질을 계속 유지해야 합니다.
Slot Registry는 Projection에 관여하지 않는다
Projection Engine은 최종적으로 다음을 만들어 냅니다.
message.user
message.assistant
tool-call
구체적인 UI Component를 참조하지 않습니다.
SlotRegistry는 별도로 선언합니다.
Chat Feature는 다음과 같이 등록합니다.
message.user
→ UserMessage renderer
message.assistant
→ AssistantMessage renderer
tool-call
→ ToolCall renderer
ToolCall Renderer는 Tool을 만나면 계속 분배합니다.
toolName = read_file
↓
tool.view
↓
read_file plugin renderer
이는 DSH의 현재 구조와 같은 관계를 유지합니다.
conversation.chat.node
↓
ToolCallTree
↓
tool.call.toolview
Cordis, React Scope, Store 같은 프로덕션 역량은 제거했습니다.
새 Tool UI를 추가해도 Chat Renderer는 수정하지 않는다
read_file Renderer는 독립적인 contribution으로 등록합니다.
일치하는 항목이 없으면 ToolCall은 generic fallback을 사용합니다.
테스트는 최종적으로 다음을 확인합니다.
tool slot dispatch: ok
따라서 Feature 확장 경로는 다음과 같습니다.
New Tool
↓
register new tool.view entry
다음 방식이 아닙니다.
modify ChatView
modify ToolCall switch
modify global component map
브라우저 Client Runtime은 Observable Snapshot만 노출한다
브라우저의 ClientSession은 다음을 보유합니다.
EventSource
ProjectionEngine
Connection State
Listeners
UI는 다음만 호출합니다.
이는 DSH의 Object Layer → React Binding이라는 생각과 일치합니다.
현재 데모는 일반 DOM을 사용합니다.
Runtime Snapshot
↓
renderSnapshot()
↓
innerHTML
React로 교체하더라도 가장 바깥쪽에 다음만 추가하면 됩니다.
Projection, Reconnect, Slot Registry를 모두 Component 안으로 옮길 필요는 없습니다.
왜 이 데모는 React를 직접 사용하지 않았는가
이 데모의 목표는 React API를 보여주는 것이 아니라 아키텍처 의존성을 검증하는 것입니다.
React를 바로 사용하면 지문이 다음에 소비될 가능성이 큽니다.
Vite
JSX
package setup
hook code
CSS
그러면 독자는 "페이지가 동작한다"는 것만 확인할 수 있을 뿐, Client Runtime이 정말 독립적인지 판단하기는 어렵습니다.
여기서는 의도적으로 DOM Binding을 사용했는데, 오히려 다음을 검증할 수 있습니다.
Projection의 출력과 Slot Composition이 React에 의존하지 않는다면, 그 둘은 비로소 Runtime과 UI Composition 계층에 속한다고 말할 수 있습니다.
15번째 편에서는 DSH의 현재 web-react 소스 코드를 통해 프로덕션급 React Binding이 어떻게 구현되는지 이미 설명했습니다.
이 데모에 아직 없는 프로덕션 역량
이 데모는 핵심 모델만 검증하며, 그대로 프로덕션 구현으로 사용해서는 안 됩니다.
최소한 다음이 빠져 있습니다.
특히 현재의 메모리 events[]는 Durable Storage이 아닙니다. Process Crash가 일어나면 모든 상태가 사라집니다.
계속 발전시킨다면 우선순위는 다음과 같아야 합니다.
1. Event Log 영속화
2. Run 상태 영속화
3. durable stream / pub-sub
4. Snapshot + Tail Recovery
5. React Binding
6. Plugin Scope / Store lifecycle
Component를 더 늘리는 것이 먼저가 아닙니다.
4번째 단위가 만들어 낸 모델
네 편을 거치면서 Web Agent는 다음 구조로 수렴할 수 있습니다.
이 구조에서 각 계층은 교체할 수 있습니다.
SSE ↔ WebSocket
Memory Log ↔ PostgreSQL / SQLite
DOM ↔ React
Simple Slot Registry ↔ Cordis + DSH Slots
Fake Agent ↔ Real Agent Loop
교체한 뒤에도 핵심적인 소유 관계는 변하지 않습니다.
설계 판단
4번째 단위는 최종적으로 세 가지 판단을 남깁니다.
첫째, Web Agent의 실행 수명은 네트워크 연결과 독립적이어야 합니다. 네트워크 연결은 transport resource이고, Run은 업무 실행입니다.
둘째, Replay, Gap Repair, Projection은 Client Runtime이 소유해야 합니다. React는 Snapshot만 소비 thereby 복구 알고리즘이 컴포넌트 트리 전체에 흩어지는 것을 방지합니다.
셋째, 플러그인화 UI는 안정적인 View Model 위에 세워져야 합니다. Feature Plugin은 renderer를 등록하고, 원시 Event를 직접 해석하지 않으며, 전역 Context를 통해 모든 Runtime Service를 암묵적으로 읽지도 않습니다.
여기까지 앞의 네 단위는 현대적인 Agent Harness의 네 가지 기본 측면을 다루었습니다.
Execution
State & Persistence
Composition
Web Runtime & UI
다음 단위에서는 이 모델들을 수렴시키면서 Vercel AI SDK, Pi, DeepSeek Harness가 경계 선택에서 어떻게 다른지 비교하고, 그 결과로 자체 Agent Runtime / Harness API를 설계할 수 있습니다.
Demo 파일
실행:
cd demo/web-runtime
npm test
npm start
참고 자료
[1] DeepSeek Harness Client Runtime: https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/client/runtime/src/client
[2] DeepSeek Harness Web Client Rules: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[3] DeepSeek Harness UI Slots: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/ui-slots/README.md