1
0
Эх сурвалжийг харах

feat(agent): emit live assistant stream frames

Tianyi Cui 1 долоо хоног өмнө
parent
commit
30e045dfad
47 өөрчлөгдсөн 2052 нэмэгдсэн , 125 устгасан
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml
  2. 24 0
      .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
  3. 24 0
      .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md
  4. 2 2
      docs/architecture.i18n.yaml
  5. 4 2
      docs/architecture.md
  6. 4 2
      docs/architecture.zh.md
  7. 2 2
      docs/event-producer-consumer.i18n.yaml
  8. 18 17
      docs/event-producer-consumer.md
  9. 18 17
      docs/event-producer-consumer.zh.md
  10. 2 2
      docs/subsystems/core.i18n.yaml
  11. 23 0
      docs/subsystems/core.md
  12. 23 0
      docs/subsystems/core.zh.md
  13. 2 2
      packages/api/gateway/README.i18n.yaml
  14. 1 1
      packages/api/gateway/README.md
  15. 1 1
      packages/api/gateway/README.zh.md
  16. 65 31
      packages/api/gateway/src/client/journal-stream.ts
  17. 52 6
      packages/api/gateway/tests/journal-stream.client.spec.ts
  18. 2 2
      packages/api/session-controller/README.i18n.yaml
  19. 1 1
      packages/api/session-controller/README.md
  20. 1 1
      packages/api/session-controller/README.zh.md
  21. 97 0
      packages/api/session-controller/src/assistant-stream.ts
  22. 176 0
      packages/api/session-controller/src/client/sessions/assistant-stream.ts
  23. 35 6
      packages/api/session-controller/src/client/sessions/session.ts
  24. 36 3
      packages/api/session-controller/src/client/transport.ts
  25. 66 7
      packages/api/session-controller/src/history.ts
  26. 52 2
      packages/api/session-controller/src/types.ts
  27. 9 1
      packages/api/session-controller/tests/fake-api.client.ts
  28. 1 0
      packages/api/session-controller/tests/manager.client.spec.ts
  29. 501 2
      packages/api/session-controller/tests/session-history-journal.host.spec.ts
  30. 2 0
      packages/api/session-controller/tests/session.client.spec.ts
  31. 311 0
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  32. 218 4
      packages/api/session-controller/tests/transport.client.spec.ts
  33. 1 0
      packages/api/session-controller/tsconfig.host.json
  34. 10 0
      packages/client/connection/src/client/fixture.ts
  35. 19 1
      packages/client/connection/tests/fixture.client.spec.ts
  36. 19 1
      packages/core/agent-loop/src/agent.ts
  37. 69 0
      packages/core/agent-loop/src/assistant-stream.ts
  38. 52 1
      packages/core/agent-loop/tests/loop.spec.ts
  39. 2 2
      packages/core/agent/README.i18n.yaml
  40. 1 1
      packages/core/agent/README.md
  41. 1 1
      packages/core/agent/README.zh.md
  42. 48 2
      packages/core/agent/src/runtime-types.ts
  43. 1 0
      packages/core/scope/src/scoped-events.generated.ts
  44. 7 0
      packages/core/scope/tests/invariant.spec.ts
  45. 30 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  46. 12 0
      packages/llm/llm/src/brand.ts
  47. 1 0
      scripts/gen-cordis-catalog.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
+2026-08-31-live-assistant-stream-frames.md: 91022a8c857ca05841efedb0e468b1d2e9d148a0
+2026-08-31-live-assistant-stream-frames.zh.md: f3f4c3138f944054d73026d7a6592611ea18c5e0

+ 24 - 0
.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md

@@ -0,0 +1,24 @@
+# Agent Note: Live assistant stream frames remain separate from the session log
+
+Status: implemented
+
+English | [中文](2026-08-31-live-assistant-stream-frames.zh.md)
+
+## Problem
+
+The session log keeps every `assistant/chunk` so replay, cold reads, telemetry, and request reconstruction observe one durable v1 history. A live consumer also needs prompt frame-by-frame presentation while a request runs. Treating a transient presentation update as a new durable event would change persistence semantics and make a process-lifetime concern survive restart.
+
+## Decision
+
+`dsh-agent-loop` emits scoped `agent/assistant-stream` frames for each model attempt. `start`, `chunk`, and `end` carry a branded process-local `LlmAttemptId`; every emitted frame advances one Session-local revision. The `start` frame captures a safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, and `end.index` equals the next chunk position. The loop appends every v1 `assistant/chunk` before its matching live chunk frame, records that exact `legacyChunkSeq`, and appends the final `assistant/message` before a committed end frame. The existing authenticated Session-follow accepts an explicit Web opt-in, opens with a cached active-attempt baseline, and carries durable events and cursorless frames in one FIFO. Each follower captures a local arrival ordinal with the opening baseline and drops buffered frames at or before that cut; frame revisions can restart at one with a replacement Agent, so they do not define the opening cut. When opening lands between a durable final message and its end frame, the Web Session exposes the active chunks, stages only the final message with identical ordered legacy seq provenance, and releases it after the matching `end.index`; an earlier retry at the same Turn and Step remains visible. Revision, dense-index, or provenance gaps re-open follow and replace the baseline. The TypeScript and Python SDK protocols do not expose these frames. The durable log remains the source of replay and model history.
+
+## Alternatives considered
+
+- **Replace `assistant/chunk` with a live-only stream** — rejected because cold reads, replay, telemetry, and the completed assistant message's source references require the durable raw chunk history.
+- **Add a durable assistant-stream event type** — rejected because process-local attempts, revisions, and reconnect presentation are not facts that survive restart or affect model reconstruction.
+- **Use an unbranded request string as the attempt key** — rejected because consumers need an opaque identity that cannot be confused with provider request IDs or durable Session IDs.
+- **Let UI Chat subscribe to a second live source** — rejected because the Session object owns stream reconciliation and UI Conversation is the sole event-source subscriber; a second source would make settlement order target-dependent.
+
+## Consequences
+
+The Web client renders in-memory chunks before persistence flush while retaining one durable v1 history, without changing `SESSION_FORMAT_VERSION` or the chunk-row encoding. A process restart has no active assistant frames; reconnect and cold replay use durable records. Cursorless notifications never advance the journal cursor, and notifications observed during durable gap repair wait for the replacement page. The frame declaration remains agent-scoped, so a listener observes only its owning Agent unless it explicitly registers globally.

+ 24 - 0
.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md

@@ -0,0 +1,24 @@
+# Agent Note: 实时 assistant 流帧与 Session log 保持分离
+
+Status: implemented
+
+[English](2026-08-31-live-assistant-stream-frames.md) | 中文
+
+## 问题
+
+Session log 保留每个 `assistant/chunk`,因此重放、冷读、遥测和请求重建都能观察同一份持久 v1 历史。实时消费方还需要在请求运行时逐帧呈现。把短暂的呈现更新当作新的持久事件会改变持久化语义,并让只属于进程生命周期的事实跨重启保留。
+
+## 决定
+
+`dsh-agent-loop` 为每次模型尝试发出作用域内的 `agent/assistant-stream` 帧。`start`、`chunk` 和 `end` 带有带品牌的进程本地 `LlmAttemptId`;每个已发出的帧都会推进一次 Session 本地 revision。`start` 帧会把壁钟时间捕获为安全整数 `startedTime`,chunk index 从零开始连续递增,`end.index` 等于下一个 chunk 位置。循环在匹配的实时 chunk 帧之前追加每个 v1 `assistant/chunk`,记录精确的 `legacyChunkSeq`,并在已提交的 end 帧之前追加最终 `assistant/message`。现有的已认证 Session-follow 接受显式 Web opt-in,以缓存的活跃尝试 baseline 打开,并在一个 FIFO 中携带持久事件和无 cursor 的帧。每个 follower 会随 opening baseline 捕获本地到达序号,并丢弃该 cut 及之前的 buffered frame;replacement Agent 的 frame revision 可以从一重新开始,因此 revision 不定义 opening cut。当 opening 位于最终持久 message 与对应 end 帧之间时,Web Session 会公开活跃 chunk,只暂存 ordered legacy seq 来源完全相同的最终 message,并在匹配的 `end.index` 到达后释放;同一 Turn 和 Step 中更早的 retry 仍保持可见。revision、连续 index 或来源缺口会重新打开 follow 并替换 baseline。TypeScript 和 Python SDK 协议不公开这些帧。持久 log 仍然是重放和模型历史的真源。
+
+## 曾考虑的替代方案
+
+- **用仅实时的流替换 `assistant/chunk`**:不采用,因为冷读、重放、遥测和已完成 assistant message 的来源引用都需要持久的原始 chunk 历史。
+- **添加持久的 assistant-stream 事件类型**:不采用,因为进程本地尝试、revision 和重连呈现不是会跨重启保留或影响模型重建的事实。
+- **用未加品牌的请求字符串作为尝试键**:不采用,因为消费方需要一个不透明身份,不能把它与 provider request ID 或持久 Session ID 混淆。
+- **让 UI Chat 订阅第二个实时 source**:不采用,因为 Session 对象拥有 stream 对账,UI Conversation 是唯一的 event-source 订阅方;第二个 source 会使结算顺序依赖 target。
+
+## 影响
+
+Web client 可以在 persistence flush 前渲染内存 chunk,同时保留一份持久 v1 历史,而不改变 `SESSION_FORMAT_VERSION` 或 chunk-row 编码。进程重启后没有活跃 assistant 帧;重连和冷重放使用持久记录。无 cursor 的通知绝不推进 journal cursor,在持久缺口修复期间观察到的通知会等待 replacement page。帧声明保持 agent 作用域,因此监听器只观察所属 Agent,除非它显式全局注册。

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 913567b76f74c2d964ea97da4720069034ed4a87
-architecture.zh.md: a3ac632b7811fbb03d6859a9fcfbe0ca4ba03744
+architecture.md: 35a4c6fdf7240033dfd0781b5eb276b0c7318e45
+architecture.zh.md: 1c7dae640f526fddfd4c004fbaa847c957214b73

+ 4 - 2
docs/architecture.md

@@ -84,7 +84,9 @@ turn/start
      step/start
      append entered messages as user/message
      derive model history from the log
-     agent/request -> llm/stream -> assistant/chunk* -> assistant/message
+     agent/request -> llm/stream -> agent/assistant-stream start
+       (assistant/chunk -> agent/assistant-stream chunk)*
+       assistant/message -> agent/assistant-stream end
      tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
      step/end
      tools owe another request, or next-step input arrived -> claim -> next step
@@ -92,7 +94,7 @@ turn/start
 turn/end
 ```
 
-`turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
+`turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` is a process-local notification that follows each matching durable chunk and final message; the Web Session-follow adapter is its only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
 
 Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 

+ 4 - 2
docs/architecture.zh.md

@@ -88,7 +88,9 @@ turn/start
      step/start
      append entered messages as user/message
      derive model history from the log
-     agent/request -> llm/stream -> assistant/chunk* -> assistant/message
+     agent/request -> llm/stream -> agent/assistant-stream start
+       (assistant/chunk -> agent/assistant-stream chunk)*
+       assistant/message -> agent/assistant-stream end
      tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
      step/end
      tools owe another request, or next-step input arrived -> claim -> next step
@@ -96,7 +98,7 @@ turn/start
 turn/end
 ```
 
-`turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
+`turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 是进程本地通知,跟随每个匹配的持久 chunk 和最终 message;Web Session-follow adapter 是它唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
 
 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 783132a5062b5374f2245eb27d866af471d9cec0
-event-producer-consumer.zh.md: b2816cf2edaf123d39e8368390234bccc7d817f4
+event-producer-consumer.md: d15fb98bcde019710927a646aca36fb20d18e78e
+event-producer-consumer.zh.md: 8dbfc447f5b629d739e13b5a9eea9a8d7152529a

+ 18 - 17
docs/event-producer-consumer.md

@@ -9,23 +9,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:240`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:313`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `session-controller` |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:202`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:211`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:240`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:248`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:229`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:274`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:287`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:303`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:221`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:331`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 18 - 17
docs/event-producer-consumer.zh.md

@@ -11,23 +11,24 @@
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:240`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:313`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `session-controller` |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:202`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:211`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:240`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:248`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:229`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:274`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:287`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:303`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:221`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:331`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 2 - 2
docs/subsystems/core.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: a59a692573c2e2756e60626268314a8ade6c546e
-core.zh.md: acea4966a30ab03c24f1aea9bcd2d33a6271a28d
+core.md: 81c73a0c7b3653a07646cc72e873cb1d137b515d
+core.zh.md: ae8c6c07db80ad54471e3bb8a34852dff5a15bc9

+ 23 - 0
docs/subsystems/core.md

@@ -803,6 +803,29 @@ Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index
 
 ### `agent/*` events
 
+<a id="agentassistant-stream--emit"></a>
+
+#### `agent/assistant-stream` — emit
+
+Process-local assistant-stream publication. The loop appends each v1 `assistant/chunk` before the matching chunk frame and appends the final `assistant/message` before a committed end frame.
+
+```ts cordis-catalog
+/**
+ * Process-local assistant-stream publication. The loop appends each v1
+ * `assistant/chunk` before the matching chunk frame and appends the final
+ * `assistant/message` before a committed end frame.
+ * @param payload.agent - the agent whose attempt produced the frame.
+ * @param payload.frame - one ordered start, chunk, or end publication.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+'agent/assistant-stream'(this: Scoped<Agent>, payload: { agent: Agent; frame: AssistantStreamFrame }): void
+```
+
+Types: [Scoped](scope.md)
+
+Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
+
 <a id="agentcreated--emit"></a>
 
 #### `agent/created` — emit

+ 23 - 0
docs/subsystems/core.zh.md

@@ -813,6 +813,29 @@ Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index
 
 ### `agent/*` events
 
+<a id="agentassistant-stream--emit"></a>
+
+#### `agent/assistant-stream` — emit
+
+Process-local assistant-stream publication. The loop appends each v1 `assistant/chunk` before the matching chunk frame and appends the final `assistant/message` before a committed end frame.
+
+```ts cordis-catalog
+/**
+ * Process-local assistant-stream publication. The loop appends each v1
+ * `assistant/chunk` before the matching chunk frame and appends the final
+ * `assistant/message` before a committed end frame.
+ * @param payload.agent - the agent whose attempt produced the frame.
+ * @param payload.frame - one ordered start, chunk, or end publication.
+ * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+ * @mode emit
+ */
+'agent/assistant-stream'(this: Scoped<Agent>, payload: { agent: Agent; frame: AssistantStreamFrame }): void
+```
+
+Types: [Scoped](scope.zh.md)
+
+Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
+
 <a id="agentcreated--emit"></a>
 
 #### `agent/created` — emit

+ 2 - 2
packages/api/gateway/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/gateway/README.md
-README.md: e6c6657963a43babc79fd3812aafddadad283787
-README.zh.md: 818840f141406c7dca99967ba9c2c62743b5b1b3
+README.md: c9f0cc726373af450da434a774279ac22e4889f6
+README.zh.md: 47699071e6e5d46039271388677e77bccaae5975

+ 1 - 1
packages/api/gateway/README.md

@@ -47,7 +47,7 @@ Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ o
 
 `ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it.
 
-`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.
+`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. Every terminal failure leaves this face as a `RemoteError`, including exhausted carrier retries and a generation that ends before its opening value, so a stream consumer discriminates the same way a unary caller does. `RemoteStreamCarrierError` names a retryable physical loss and reaches a domain only as the `carrierFailed` callback argument, never as a terminal outcome. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. A domain may also carry cursorless notifications: they never advance or repair the durable cursor, and notifications received during gap repair publish only after the replacement page commits. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped.
 
 `ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under bounded jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier.
 

+ 1 - 1
packages/api/gateway/README.zh.md

@@ -47,7 +47,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
 
 `ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是 store——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。
 
-`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。
+`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。
 
 `ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按有界且带抖动的指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。
 

+ 65 - 31
packages/api/gateway/src/client/journal-stream.ts

@@ -13,13 +13,16 @@ function protocolViolation(message: string): RemoteError<'gateway/internal'> {
   return new RemoteError('gateway/internal', message, {})
 }
 
-/** Transport-neutral opening snapshot or journal entry. */
-export type RemoteJournalFrame<Entry, Cursor, Page> =
+/** Transport-neutral opening snapshot, durable entry, or cursorless notification. */
+export type RemoteJournalFrame<Entry, Cursor, Page, Notification = never> =
   | { readonly type: 'opened'; readonly cursor: Cursor; readonly page: Page }
   | { readonly type: 'entry'; readonly entry: Entry }
+  | ([Notification] extends [never]
+    ? never
+    : { readonly type: 'notification'; readonly notification: Notification })
 
-/** One committed journal-window update. */
-export type RemoteJournalChange<Page, Entry> =
+/** One journal-window update or cursorless domain notification. */
+export type RemoteJournalChange<Page, Entry, Notification = never> =
   | {
     readonly type: 'replace'
     readonly page: Page
@@ -33,8 +36,13 @@ export type RemoteJournalChange<Page, Entry> =
     readonly hasMore: boolean
   }
   | { readonly type: 'append'; readonly entry: Entry }
+  | ([Notification] extends [never]
+    ? never
+    : { readonly type: 'notification'; readonly notification: Notification })
 
-type JournalStreamItem<Page, Entry, Cursor> = RemoteStreamItem<RemoteJournalFrame<Entry, Cursor, Page>>
+type JournalStreamItem<Page, Entry, Cursor, Notification> = RemoteStreamItem<
+  RemoteJournalFrame<Entry, Cursor, Page, Notification>
+>
 
 /** Gateway capability used to create one reconnecting Remote stream. */
 export interface RemoteStreamFactory {
@@ -47,7 +55,7 @@ export interface RemoteStreamFactory {
 }
 
 /** Domain publication and cursor operations for one addressed journal stream. */
-export interface RemoteJournalStreamOptions<Page, Entry, Cursor> {
+export interface RemoteJournalStreamOptions<Page, Entry, Cursor, Notification = never> {
   /** Diagnostic stream name used in protocol failures. */
   readonly name: string
   /** Cursor representing a journal with no entries. */
@@ -64,8 +72,8 @@ export interface RemoteJournalStreamOptions<Page, Entry, Cursor> {
   readonly compare: (left: Cursor, right: Cursor) => number
   /** Test whether the right cursor immediately follows the left cursor. */
   readonly follows: (left: Cursor, right: Cursor) => boolean
-  /** Apply one complete journal-window change. */
-  readonly publish: (change: RemoteJournalChange<Page, Entry>) => void
+  /** Apply one complete journal-window change or cursorless notification. */
+  readonly publish: (change: RemoteJournalChange<Page, Entry, Notification>) => void
   /** Observe a retryable carrier loss before reconnection. */
   readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
   /** Publish a terminal stream, page, or protocol failure after opening. */
@@ -77,9 +85,12 @@ export interface RemoteJournalStreamOptions<Page, Entry, Cursor> {
  *
  * The domain retains its published window during reconnection. A replacement is
  * published only after the opening page reaches the generation's cursor.
+ * Notifications never change a cursor and wait behind an in-flight gap repair.
  */
-export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = void> {
-  private readonly stream: RemoteStream<RemoteJournalFrame<Entry, Cursor, Page>>
+export abstract class RemoteJournalStream<
+  Page, Entry, Cursor, PageRequest = void, Notification = never,
+> {
+  private readonly stream: RemoteStream<RemoteJournalFrame<Entry, Cursor, Page, Notification>>
   private initialRequest!: PageRequest
   private resumeCursor: Cursor | undefined
   private hasResumeCursor = false
@@ -91,7 +102,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   private disposed = false
   private done: Promise<void> | undefined
   private closing: Promise<void> | undefined
-  private pendingNext: Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>> | undefined
+  private pendingNext: Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor, Notification>>> | undefined
 
   /**
    * @param remote - Gateway factory for the reconnecting physical-generation stream.
@@ -99,9 +110,9 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
    */
   protected constructor(
     remote: RemoteStreamFactory,
-    private readonly options: RemoteJournalStreamOptions<Page, Entry, Cursor>,
+    private readonly options: RemoteJournalStreamOptions<Page, Entry, Cursor, Notification>,
   ) {
-    this.stream = remote.$stream<RemoteJournalFrame<Entry, Cursor, Page>>({
+    this.stream = remote.$stream<RemoteJournalFrame<Entry, Cursor, Page, Notification>>({
       name: options.name,
       open: signal => this.follow(this.initialRequest, signal),
       ended: accepted => accepted
@@ -124,7 +135,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   protected abstract follow(
     request: PageRequest,
     signal: AbortSignal,
-  ): AsyncIterable<RemoteJournalFrame<Entry, Cursor, Page>>
+  ): AsyncIterable<RemoteJournalFrame<Entry, Cursor, Page, Notification>>
 
   /**
    * Read one journal page through the addressed domain source.
@@ -222,7 +233,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   private async consume(
-    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor, Notification>>,
   ): Promise<void> {
     try {
       while (true) {
@@ -236,6 +247,10 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
         if (item.value.type === 'opened') {
           throw protocolViolation(`${this.options.name} emitted more than one opening cursor`)
         }
+        if (item.value.type === 'notification') {
+          this.publishNotification(item.value.notification)
+          continue
+        }
         await this.acceptEntry(item.value.entry, item, iterator)
       }
     } catch (error) {
@@ -244,7 +259,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   private replaceGeneration(
-    initial: JournalStreamItem<Page, Entry, Cursor>,
+    initial: JournalStreamItem<Page, Entry, Cursor, Notification>,
     resumed: boolean,
   ): void {
     const opening = this.opening(initial, resumed)
@@ -252,7 +267,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   private opening(
-    item: RemoteStreamItem<RemoteJournalFrame<Entry, Cursor, Page>>,
+    item: RemoteStreamItem<RemoteJournalFrame<Entry, Cursor, Page, Notification>>,
     resumed: boolean,
   ): { readonly cursor: Cursor; readonly page: Page } {
     if (item.value.type !== 'opened') {
@@ -289,8 +304,8 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
 
   private async acceptEntry(
     entry: Entry,
-    item: JournalStreamItem<Page, Entry, Cursor>,
-    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
+    item: JournalStreamItem<Page, Entry, Cursor, Notification>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor, Notification>>,
   ): Promise<void> {
     const { first, last: cursor } = this.entryRange(entry)
     const last = this.lastCursor as Cursor
@@ -307,6 +322,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
         item.signal,
         iterator,
         [entry],
+        [],
       )
       if (superseded !== undefined) {
         this.replaceGeneration(superseded, true)
@@ -324,9 +340,10 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     requiredCursor: Cursor,
     generation: number,
     signal: AbortSignal,
-    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor, Notification>>,
     queued: Entry[],
-  ): Promise<JournalStreamItem<Page, Entry, Cursor> | undefined> {
+    notifications: Notification[],
+  ): Promise<JournalStreamItem<Page, Entry, Cursor, Notification> | undefined> {
     let read = await this.readPageWhileFollowing(
       request,
       requiredCursor,
@@ -334,6 +351,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       signal,
       iterator,
       queued,
+      notifications,
     )
     if (read.type === 'superseded') return read.item
     let page = read.page
@@ -348,6 +366,7 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
         signal,
         iterator,
         queued,
+        notifications,
       )
       if (read.type === 'superseded') return read.item
       page = read.page
@@ -369,6 +388,9 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       entries,
       hasMore: this.options.hasMore(page),
     })
+    for (const notification of notifications) {
+      this.publishNotification(notification)
+    }
     return undefined
   }
 
@@ -377,11 +399,12 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     through: Cursor,
     generation: number,
     signal: AbortSignal,
-    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor, Notification>>,
     queued: Entry[],
+    notifications: Notification[],
   ): Promise<
     | { readonly type: 'page'; readonly page: Page }
-    | { readonly type: 'superseded'; readonly item: JournalStreamItem<Page, Entry, Cursor> }
+    | { readonly type: 'superseded'; readonly item: JournalStreamItem<Page, Entry, Cursor, Notification> }
   > {
     const page = this.readPage(request, through, signal).then(
       value => ({ type: 'page' as const, value }),
@@ -413,18 +436,22 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
       if (item.value.type === 'opened') {
         throw protocolViolation(`${this.options.name} emitted more than one opening cursor`)
       }
+      if (item.value.type === 'notification') {
+        notifications.push(item.value.notification)
+        continue
+      }
       queued.push(item.value.entry)
     }
   }
 
   private async awaitReplacementGeneration(
     generation: number,
-    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
-    initial: Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>>,
-  ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem<Page, Entry, Cursor> }> {
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor, Notification>>,
+    initial: Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor, Notification>>>,
+  ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem<Page, Entry, Cursor, Notification> }> {
     let pending = initial
     while (true) {
-      let next: IteratorResult<JournalStreamItem<Page, Entry, Cursor>>
+      let next: IteratorResult<JournalStreamItem<Page, Entry, Cursor, Notification>>
       try {
         next = await pending
       } finally {
@@ -475,15 +502,15 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
   }
 
   private nextResult(
-    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
-  ): Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>> {
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor, Notification>>,
+  ): Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor, Notification>>> {
     this.pendingNext ??= iterator.next()
     return this.pendingNext
   }
 
   private async takeNext(
-    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor>>,
-  ): Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor>>> {
+    iterator: AsyncIterator<JournalStreamItem<Page, Entry, Cursor, Notification>>,
+  ): Promise<IteratorResult<JournalStreamItem<Page, Entry, Cursor, Notification>>> {
     const pending = this.nextResult(iterator)
     try {
       return await pending
@@ -496,6 +523,13 @@ export abstract class RemoteJournalStream<Page, Entry, Cursor, PageRequest = voi
     this.pendingNext = undefined
   }
 
+  private publishNotification(notification: Notification): void {
+    this.options.publish({
+      type: 'notification',
+      notification,
+    } as RemoteJournalChange<Page, Entry, Notification>)
+  }
+
   private repairPageRequest(): PageRequest {
     return this.repairRequest(this.initialRequest)
   }

+ 52 - 6
packages/api/gateway/tests/journal-stream.client.spec.ts

@@ -26,7 +26,7 @@ interface PageRequest {
   readonly limit?: number
 }
 
-type JournalFrame = RemoteJournalFrame<Entry, number, Page>
+type JournalFrame = RemoteJournalFrame<Entry, number, Page, string>
 type ScriptedFrame = JournalFrame
 
 interface Generation {
@@ -70,7 +70,7 @@ const STREAM_FACTORY = {
   },
 }
 
-class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageRequest> {
+class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageRequest, string> {
   constructor(
     private readonly generations: Generation[],
     private readonly pages: PageSource[],
@@ -78,7 +78,7 @@ class FixtureJournal extends RemoteJournalStream<Page, Entry, number, PageReques
     private readonly pageRequests: PageRequest[],
     private readonly pageCursors: number[],
     private readonly followRequests: PageRequest[],
-    changes: RemoteJournalChange<Page, Entry>[],
+    changes: RemoteJournalChange<Page, Entry, string>[],
     failed: (error: unknown) => void,
     factory: RemoteStreamFactory = STREAM_FACTORY,
   ) {
@@ -143,8 +143,8 @@ function journalFixture(
   pages: PageSource[],
   factory: RemoteStreamFactory = STREAM_FACTORY,
 ): {
-  readonly journal: RemoteJournalStream<Page, Entry, number, PageRequest>
-  readonly changes: RemoteJournalChange<Page, Entry>[]
+  readonly journal: RemoteJournalStream<Page, Entry, number, PageRequest, string>
+  readonly changes: RemoteJournalChange<Page, Entry, string>[]
   readonly failed: ReturnType<typeof vi.fn>
   readonly calls: string[]
   readonly pageRequests: PageRequest[]
@@ -155,7 +155,7 @@ function journalFixture(
   const pageRequests: PageRequest[] = []
   const pageCursors: number[] = []
   const followRequests: PageRequest[] = []
-  const changes: RemoteJournalChange<Page, Entry>[] = []
+  const changes: RemoteJournalChange<Page, Entry, string>[] = []
   const failed = vi.fn()
   const journal = new FixtureJournal(
     generations,
@@ -204,6 +204,52 @@ function controlledFactory(
 }
 
 describe('RemoteJournalStream', () => {
+  it('publishes cursorless notifications without advancing the durable page cursor', async () => {
+    const live = Promise.withResolvers<ScriptedFrame>()
+    const fixture = journalFixture(
+      [{ frames: [opened(-1, page('empty', [])), { type: 'notification', notification: 'partial' }, live.promise], hold: true }],
+      [page('older', [])],
+    )
+
+    await fixture.journal.open({})
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) })
+    await fixture.journal.prepend({})
+    live.resolve({ type: 'entry', entry: { seq: 0 } })
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(4) })
+
+    expect(fixture.pageCursors).toEqual([-1])
+    expect(fixture.changes.map(change => change.type)).toEqual([
+      'replace', 'notification', 'prepend', 'append',
+    ])
+    await fixture.journal.dispose()
+  })
+
+  it('defers notifications behind a durable gap until replacement commits', async () => {
+    const repair = Promise.withResolvers<Page>()
+    const fixture = journalFixture(
+      [{
+        frames: [
+          opened(0, page('initial', [0])),
+          { type: 'entry', entry: { seq: 2 } },
+          { type: 'notification', notification: 'after-gap' },
+        ],
+        hold: true,
+      }],
+      [repair.promise],
+    )
+
+    await fixture.journal.open({})
+    await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) })
+    expect(fixture.changes.map(change => change.type)).toEqual(['replace'])
+    repair.resolve(page('repair', [0, 1, 2]))
+    await vi.waitFor(() => { expect(fixture.changes).toHaveLength(3) })
+
+    expect(fixture.changes.map(change => change.type)).toEqual([
+      'replace', 'replace', 'notification',
+    ])
+    await fixture.journal.dispose()
+  })
+
   it('replaces from pages whose entries cover contiguous cursor ranges', async () => {
     const snapshot = rangedPage(
       'ranged',

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: b3d22a340fff6a49270de520a043a1c8dfdbf096
-README.zh.md: 45590a53db17b32cc59f6107957c284b53ee8302
+README.md: 81baecb0f08bd469534ec980e7cfa040dd3800fa
+README.zh.md: 5267c8cb20461ecbdb44b2b98634003d9c22904e

+ 1 - 1
packages/api/session-controller/README.md

@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry a discriminated `SessionHistory
 
 Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
 
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless assistant notifications: each opening carries active attempts with their `startedTime`, current chunks, and exact v1 seq provenance. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A revision or dense-index gap reopens follow. If an opening observes a final durable message before its committed end frame, the Session object exposes the active chunks but stages only the message whose ordered source seqs match that attempt, then publishes it when `end.index` equals the next chunk position. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
 
 The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 

+ 1 - 1
packages/api/session-controller/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
 
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 assistant 通知:每个 opening 携带活跃尝试的 `startedTime`、当前 chunk 和精确 v1 seq 来源。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。revision 或连续 index 缺口会重新打开 follow。如果 opening 已观察到最终持久 message,但尚未观察到对应的已提交 end 帧,Session 对象会公开活跃 chunk,但只暂存 ordered source seq 与该尝试匹配的 message;当 `end.index` 等于下一个 chunk 位置时,再发布该 message。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
 
 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
 

+ 97 - 0
packages/api/session-controller/src/assistant-stream.ts

@@ -0,0 +1,97 @@
+/** Process-local assistant state retained for reconnecting Web followers. */
+
+import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
+import type {
+  SessionAssistantStreamAttempt,
+  SessionAssistantStreamBaseline,
+} from './types.ts'
+
+type ChunkFrame = Extract<AssistantStreamFrame, { type: 'chunk' }>
+
+interface MutableAttempt {
+  readonly attemptId: SessionAssistantStreamAttempt['attemptId']
+  readonly startedTime: number
+  readonly turn: number
+  readonly step: number
+  readonly chunks: ChunkFrame[]
+  readonly legacyChunkSeqs: number[]
+}
+
+const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0, attempts: [] }
+
+/**
+ * Folds dense Agent frames and materializes one shared immutable reconnect
+ * baseline per accepted revision.
+ */
+export class SessionAssistantStreamAccumulator {
+  private readonly attempts = new Map<string, MutableAttempt>()
+  private revision = 0
+  private snapshotValue: SessionAssistantStreamBaseline = EMPTY_BASELINE
+  private dirty = false
+
+  /**
+   * Fold one trusted frame from the current attached Agent lifecycle.
+   * @param frame - next dense process-local Assistant frame.
+   */
+  accept(frame: AssistantStreamFrame): void {
+    if (frame.type === 'start' && frame.revision === 1 && this.revision !== 0) {
+      this.attempts.clear()
+      this.revision = 0
+    }
+    if (frame.revision !== this.revision + 1) {
+      this.attempts.clear()
+      this.revision = frame.revision
+      this.dirty = true
+      return
+    }
+    this.revision = frame.revision
+    switch (frame.type) {
+      case 'start':
+        this.attempts.set(String(frame.attemptId), {
+          attemptId: frame.attemptId,
+          startedTime: frame.startedTime,
+          turn: frame.turn,
+          step: frame.step,
+          chunks: [],
+          legacyChunkSeqs: [],
+        })
+        break
+      case 'chunk': {
+        const attempt = this.attempts.get(String(frame.attemptId))
+        if (attempt === undefined || frame.index !== attempt.chunks.length) {
+          this.attempts.clear()
+          break
+        }
+        attempt.chunks.push(frame)
+        attempt.legacyChunkSeqs.push(frame.legacyChunkSeq)
+        break
+      }
+      case 'end':
+        this.attempts.delete(String(frame.attemptId))
+        break
+    }
+    this.dirty = true
+  }
+
+  /**
+   * Read the cached reconnect baseline, materializing it after a state change.
+   * @returns the identity-stable baseline for the latest accepted revision.
+   */
+  snapshot(): SessionAssistantStreamBaseline {
+    if (!this.dirty) return this.snapshotValue
+    this.snapshotValue = {
+      revision: this.revision,
+      attempts: [...this.attempts.values()].map(attempt => ({
+        attemptId: attempt.attemptId,
+        startedTime: attempt.startedTime,
+        turn: attempt.turn,
+        step: attempt.step,
+        chunks: attempt.chunks.map(frame => frame.chunk as JsonValue),
+        legacyChunkSeqs: [...attempt.legacyChunkSeqs],
+      })),
+    }
+    this.dirty = false
+    return this.snapshotValue
+  }
+}

+ 176 - 0
packages/api/session-controller/src/client/sessions/assistant-stream.ts

@@ -0,0 +1,176 @@
+/** Web presentation fold joining durable v1 events with transient assistant frames. */
+
+import type {
+  SessionAssistantStreamBaseline,
+  SessionAssistantStreamFrame,
+} from '../../types.ts'
+import type {
+  SessionEventLikeEntry,
+  SessionLiveEventEntry,
+} from '../contract/events.ts'
+
+interface ActiveAttempt {
+  readonly startedTime: number
+  readonly turn: number
+  readonly step: number
+  readonly legacyChunkSeqs: Set<number>
+  nextIndex: number
+}
+
+/** One Web publication decision from the assistant stream fold. */
+export type ClientAssistantStreamResult =
+  | { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
+  | { readonly type: 'rebaseline' }
+  | undefined
+
+function positionKey(turn: number, step: number): string {
+  return `${String(turn)}:${String(step)}`
+}
+
+function sameSeqs(left: readonly number[], right: readonly number[]): boolean {
+  return left.length === right.length && left.every((seq, index) => seq === right[index])
+}
+
+/**
+ * Keeps transient Assistant presentation behind one small interface. Durable
+ * chunks and final messages publish only at their matching live frame.
+ */
+export class ClientAssistantStream {
+  private readonly attempts = new Map<string, ActiveAttempt>()
+  private readonly pendingChunks = new Map<number, SessionLiveEventEntry>()
+  private readonly pendingMessages = new Map<string, SessionLiveEventEntry>()
+  private publishedSeqs = new Set<number>()
+
+  /**
+   * Replace the durable Web window and adopt an optional reconnect baseline.
+   * @param entries - complete event window from the journal replacement.
+   * @param baseline - active process-local attempts for a follow opening.
+   * @returns the same durable window; baseline seqs suppress later duplicate live appends.
+   */
+  replace(
+    entries: readonly SessionEventLikeEntry[],
+    baseline?: SessionAssistantStreamBaseline,
+  ): readonly SessionEventLikeEntry[] {
+    this.pendingChunks.clear()
+    this.pendingMessages.clear()
+    this.attempts.clear()
+    if (baseline !== undefined) {
+      for (const attempt of baseline.attempts) {
+        this.attempts.set(String(attempt.attemptId), {
+          startedTime: attempt.startedTime,
+          turn: attempt.turn,
+          step: attempt.step,
+          legacyChunkSeqs: new Set(attempt.legacyChunkSeqs),
+          nextIndex: attempt.chunks.length,
+        })
+      }
+    }
+    const visible = entries.filter((entry) => {
+      if (entry.type !== 'event' || entry.event.type !== 'assistant/message'
+        || entry.event.surfaceOp !== 'append') return true
+      const attempt = this.attemptForSettlement(entry.event)
+      if (attempt === undefined) return true
+      this.pendingMessages.set(positionKey(attempt.turn, attempt.step), entry)
+      return false
+    })
+    this.publishedSeqs = new Set(visible.map(entry => entry.event.seq))
+    return visible
+  }
+
+  /**
+   * Stage one durable tail event when an active attempt owns its publication.
+   * @param entry - next cursor-validated durable event.
+   * @returns the entry for immediate publication, or undefined while staged.
+   */
+  acceptDurable(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
+    const event = entry.event
+    if (event.type === 'assistant/chunk') {
+      const attempt = this.attemptFor(event.data.turn, event.data.step)
+      if (attempt === undefined) return this.publish(entry)
+      this.pendingChunks.set(event.seq, entry)
+      return undefined
+    }
+    if (event.type === 'assistant/message') {
+      if (event.surfaceOp !== 'append') return this.publish(entry)
+      const attempt = this.attemptFor(event.data.turn, event.data.step)
+      if (attempt === undefined) return this.publish(entry)
+      this.pendingMessages.set(positionKey(event.data.turn, event.data.step), entry)
+      return undefined
+    }
+    return this.publish(entry)
+  }
+
+  /**
+   * Fold one validated transient frame and release its matching durable event.
+   * @param frame - next dense process-local frame.
+   * @returns one durable event whose Web publication commits at this frame.
+   */
+  acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
+    switch (frame.type) {
+      case 'start':
+        this.attempts.set(String(frame.attemptId), {
+          startedTime: frame.startedTime,
+          turn: frame.turn,
+          step: frame.step,
+          legacyChunkSeqs: new Set(),
+          nextIndex: 0,
+        })
+        return undefined
+      case 'chunk': {
+        const attempt = this.attempts.get(String(frame.attemptId))
+        if (attempt === undefined || frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
+        attempt.nextIndex += 1
+        attempt.legacyChunkSeqs.add(frame.legacyChunkSeq)
+        if (this.publishedSeqs.has(frame.legacyChunkSeq)) return undefined
+        const entry = this.pendingChunks.get(frame.legacyChunkSeq)
+        if (entry === undefined) return { type: 'rebaseline' }
+        this.pendingChunks.delete(frame.legacyChunkSeq)
+        return this.publish(entry)
+      }
+      case 'end': {
+        const attempt = this.attempts.get(String(frame.attemptId))
+        this.attempts.delete(String(frame.attemptId))
+        if (attempt === undefined) return { type: 'rebaseline' }
+        if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
+        if (!sameSeqs([...attempt.legacyChunkSeqs], frame.legacyChunkSeqs)) {
+          return { type: 'rebaseline' }
+        }
+        const key = positionKey(attempt.turn, attempt.step)
+        const entry = this.pendingMessages.get(key)
+        if (entry === undefined) {
+          return frame.outcome === 'aborted' ? undefined : { type: 'rebaseline' }
+        }
+        if (entry.event.type !== 'assistant/message') return { type: 'rebaseline' }
+        const sourceEventSeqs = entry.event.sourceEventSeqs
+        if (sourceEventSeqs === undefined || !sameSeqs(sourceEventSeqs, frame.legacyChunkSeqs)) {
+          return { type: 'rebaseline' }
+        }
+        this.pendingMessages.delete(key)
+        return this.publish(entry)
+      }
+    }
+  }
+
+  private attemptFor(turn: number, step: number): ActiveAttempt | undefined {
+    return [...this.attempts.values()].find(attempt => (
+      attempt.turn === turn && attempt.step === step
+    ))
+  }
+
+  private attemptForSettlement(
+    event: Extract<SessionLiveEventEntry['event'], { type: 'assistant/message' }>,
+  ): ActiveAttempt | undefined {
+    const sourceEventSeqs = event.sourceEventSeqs
+    if (sourceEventSeqs === undefined) return undefined
+    return [...this.attempts.values()].find(attempt => (
+      attempt.turn === event.data.turn
+      && attempt.step === event.data.step
+      && sameSeqs([...attempt.legacyChunkSeqs], sourceEventSeqs)
+    ))
+  }
+
+  private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
+    this.publishedSeqs.add(entry.event.seq)
+    return { type: 'publish', entry }
+  }
+}

+ 35 - 6
packages/api/session-controller/src/client/sessions/session.ts

@@ -12,6 +12,7 @@ import type {
   PromptContentPart,
   QueueAction,
   SessionAddress,
+  SessionAssistantStreamBaseline,
   SessionControlFrame,
   SessionProjectionBaseline,
   SessionQueuedItem,
@@ -35,6 +36,10 @@ import { ProjectionValueStore } from './projection-store.ts'
 import type { ProjectionsBaseline } from './projection-store.ts'
 import { resolvedClientTimeZone } from '../time-zone.ts'
 import { SessionQueueMirror } from './queue-mirror.ts'
+import {
+  ClientAssistantStream,
+  type ClientAssistantStreamResult,
+} from './assistant-stream.ts'
 
 function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBaseline {
   return {
@@ -95,6 +100,7 @@ export class Session implements SessionFace {
   private jumpPromise: Promise<void> | null = null
   /** Authoritative stream-only inbox snapshot; pending work never hits history. */
   private readonly queueMirror = new SessionQueueMirror()
+  private readonly assistantStream = new ClientAssistantStream()
   private running = false
   private address: SubagentAddress | undefined
   private parentAvailable: boolean | undefined
@@ -620,27 +626,50 @@ export class Session implements SessionFace {
           change.entries,
           change.hasMore,
           change.page.projections === undefined ? undefined : projectionsBaseline(change.page.projections),
+          change.page.assistantStream,
         )
         return
       case 'prepend':
         this.prependWindow(change.entries, change.hasMore)
         return
       case 'append':
-        if (this.appendLive(change.entry)) this.notifier.markDirty()
+        this.publishAssistantEntry(this.assistantStream.acceptDurable(change.entry))
+        return
+      case 'assistant-stream':
+        this.publishAssistantEntry(this.assistantStream.acceptFrame(change.frame))
     }
   }
 
   /** Replace the complete contiguous window and apply page-owned projection metadata. */
-  private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
-    this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
+  private installWindow(
+    entries: readonly SessionEventLikeEntry[],
+    hasMore: boolean,
+    projections?: ProjectionsBaseline,
+    assistantStream?: SessionAssistantStreamBaseline,
+  ): void {
+    const visible = this.assistantStream.replace(entries, assistantStream)
+    this.baseSeq = SessionLogOffset(visible[0]?.event.seq ?? 0)
     this.hasMore = hasMore
-    if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
+    if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
     if (projections !== undefined) this.projections.seed(projections)
-    this.eventSource.replace(entries, hasMore)
-    for (const entry of entries) this.observeSubmissionEvent(entry.event)
+    this.eventSource.replace(visible, hasMore)
+    for (const entry of visible) this.observeSubmissionEvent(entry.event)
     this.notifier.markDirty()
   }
 
+  private publishAssistantEntry(result: ClientAssistantStreamResult): void {
+    if (result?.type === 'rebaseline') {
+      const events = this.events
+      queueMicrotask(() => {
+        if (events !== undefined && this.events === events) events.restart()
+      })
+      return
+    }
+    if (result?.type === 'publish' && this.appendLive(result.entry)) {
+      this.notifier.markDirty()
+    }
+  }
+
   /** Prepend one stream-validated history page. */
   private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
     this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)

+ 36 - 3
packages/api/session-controller/src/client/transport.ts

@@ -12,6 +12,8 @@ import {
 } from '@deepseek-ai/dsh-api-gateway/client'
 import type {
   SessionAddress,
+  SessionAssistantStreamBaseline,
+  SessionAssistantStreamFrame,
   SessionControlFrame,
   SessionHistoryRecord,
   SessionPage,
@@ -40,6 +42,7 @@ export type SessionRemote = ClientRemote['session']
 /** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */
 interface SessionJournalPage extends SessionPage {
   readonly projections?: SessionProjectionBaseline
+  readonly assistantStream?: SessionAssistantStreamBaseline
 }
 
 /** One complete publication from the Session journal stream. */
@@ -51,9 +54,12 @@ export type SessionJournalChange =
     readonly hasMore: boolean
   }
   | { readonly type: 'append'; readonly entry: SessionLiveEventEntry }
+  | { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
 
 function toSessionJournalChange(
-  change: RemoteJournalChange<SessionJournalPage, SessionHistoryRecord>,
+  change: RemoteJournalChange<
+    SessionJournalPage, SessionHistoryRecord, SessionAssistantStreamFrame
+  >,
 ): SessionJournalChange {
   switch (change.type) {
     case 'replace':
@@ -72,6 +78,8 @@ function toSessionJournalChange(
         entry: change.entry as unknown as SessionLiveEventEntry,
       }
     }
+    case 'notification':
+      return { type: 'assistant-stream', frame: change.notification }
   }
 }
 
@@ -136,7 +144,8 @@ export class SessionEventStream extends RemoteJournalStream<
   SessionJournalPage,
   SessionHistoryRecord,
   number,
-  ClientSessionPageRequest
+  ClientSessionPageRequest,
+  SessionAssistantStreamFrame
 > {
   /**
    * @param remote - generated Session namespace and Gateway stream factory.
@@ -169,12 +178,24 @@ export class SessionEventStream extends RemoteJournalStream<
   protected override async * follow(
     request: ClientSessionPageRequest,
     signal: AbortSignal,
-  ): AsyncIterable<RemoteJournalFrame<SessionHistoryRecord, number, SessionJournalPage>> {
+  ): AsyncIterable<RemoteJournalFrame<
+    SessionHistoryRecord, number, SessionJournalPage, SessionAssistantStreamFrame
+  >> {
+    let assistantRevision: number | undefined
     for await (const frame of this.remote.session.follow({
       address: this.address,
+      assistantStream: true,
       ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
     }, signal)) {
       if (frame.type === 'snapshot') {
+        if (frame.assistantStream === undefined) {
+          throw new RemoteError(
+            'gateway/internal',
+            'session assistant stream omitted its opted-in opening baseline',
+            {},
+          )
+        }
+        assistantRevision = frame.assistantStream.revision
         yield {
           type: 'opened',
           cursor: frame.cursor,
@@ -182,10 +203,22 @@ export class SessionEventStream extends RemoteJournalStream<
             records: frame.records,
             hasMore: frame.hasMore,
             projections: frame.projections,
+            assistantStream: frame.assistantStream,
           },
         }
         continue
       }
+      if (frame.type === 'assistant-stream') {
+        const expected = (assistantRevision ?? 0) + 1
+        if (frame.frame.revision !== expected) {
+          throw new RemoteStreamCarrierError(
+            `session assistant stream skipped revision ${String(expected)}`,
+          )
+        }
+        assistantRevision = frame.frame.revision
+        yield { type: 'notification', notification: frame.frame }
+        continue
+      }
       yield { type: 'entry', entry: frame }
     }
   }

+ 66 - 7
packages/api/session-controller/src/history.ts

@@ -2,6 +2,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import { Deque } from '@deepseek-ai/dsh-deque'
+import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
 import {
   isAppendSurfaceEvent,
   SessionLogOffset,
@@ -18,8 +19,10 @@ import type {
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import type {} from '@deepseek-ai/dsh-subagent'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type {
   SessionAddress,
+  SessionAssistantStreamFrame,
   SessionChunkRun,
   SessionEventEntry,
   SessionFollowRequest,
@@ -32,6 +35,7 @@ import type {
   SessionWireHeader,
   SessionWireEvent,
 } from './types.ts'
+import { SessionAssistantStreamAccumulator } from './assistant-stream.ts'
 
 const DEFAULT_MAX_MESSAGES = 50
 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
@@ -39,6 +43,7 @@ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
 /** Implements cold-safe history operations delegated by the Session Controller. */
 export class SessionHistoryController {
   private readonly closeFollowers = new Set<() => void>()
+  private readonly assistantStreams = new Map<SessionId, SessionAssistantStreamAccumulator>()
 
   /**
    * @param ctx - Host context carrying Session query and projection services.
@@ -48,6 +53,17 @@ export class SessionHistoryController {
     private readonly ctx: Context,
     private readonly promote: (observation: SessionObservation) => void,
   ) {
+    ctx.on('agent/assistant-stream', ({ agent, frame }) => {
+      let stream = this.assistantStreams.get(agent.session.id)
+      if (stream === undefined) {
+        stream = new SessionAssistantStreamAccumulator()
+        this.assistantStreams.set(agent.session.id, stream)
+      }
+      stream.accept(frame)
+    }, { global: true })
+    ctx.on('agent/disposed', ({ agent }) => {
+      this.assistantStreams.delete(agent.session.id)
+    }, { global: true })
     ctx.effect(() => () => {
       for (const close of this.closeFollowers) close()
       this.closeFollowers.clear()
@@ -100,14 +116,22 @@ export class SessionHistoryController {
    * Follow events appended after an initial cursor on one durable address.
    * @param request - durable address and last committed sequence already held by the caller.
    * @param signal - stream cancellation owned by the Remote carrier.
-   * @returns a complete opening snapshot followed by gap-free event frames.
+   * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames.
    */
   async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
     validateFollowRequest(request)
     const { address } = request
     const target = addressId(address)
-    const buffered = new Deque<SessionEvent>()
+    const buffered = new Deque<
+      | { readonly type: 'event'; readonly event: SessionEvent }
+      | {
+        readonly type: 'assistant-stream'
+        readonly frame: SessionAssistantStreamFrame
+        readonly ordinal: number
+      }
+    >()
     let snapshotCursor: SessionSeqCursor | undefined
+    let assistantStreamOrdinal = 0
     let wake: (() => void) | undefined
     const notify = (): void => {
       const resume = wake
@@ -122,7 +146,7 @@ export class SessionHistoryController {
     this.closeFollowers.add(close)
     const disposeEvent = this.ctx.on('session/event', (session, event) => {
       if (session.id !== target) return
-      buffered.pushBack(event)
+      buffered.pushBack({ type: 'event', event })
       notify()
     }, { global: true })
     const disposeCreated = this.ctx.on('session/created', (session) => {
@@ -134,10 +158,21 @@ export class SessionHistoryController {
         ? session.firstLiveSeq
         : SessionLogOffset(snapshotCursor + 1))
       for (let index = suffix.length - 1; index >= 0; index -= 1) {
-        buffered.pushFront(suffix[index] as SessionEvent)
+        buffered.pushFront({ type: 'event', event: suffix[index] as SessionEvent })
       }
       notify()
     }, { global: true })
+    const disposeAssistantStream = request.assistantStream !== true
+      ? undefined
+      : this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
+        if (agent.session.id !== target) return
+        buffered.pushBack({
+          type: 'assistant-stream',
+          frame: wireAssistantStreamFrame(frame),
+          ordinal: ++assistantStreamOrdinal,
+        })
+        notify()
+      }, { global: true })
     const onAbort = (): void => { notify() }
     signal.addEventListener('abort', onAbort, { once: true })
     try {
@@ -147,6 +182,14 @@ export class SessionHistoryController {
       const cursor = source.cursor
       snapshotCursor = cursor
       const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
+      const assistantStream = request.assistantStream === true
+        ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0, attempts: [] }
+        : undefined
+      // The accumulator snapshot and this watermark are synchronous. Frames
+      // through the cut are represented or superseded by that baseline,
+      // including larger revisions from a retired Agent; later revision
+      // resets reach Client continuity validation.
+      const assistantStreamOrdinalCut = assistantStreamOrdinal
       yield {
         type: 'snapshot',
         header: wireHeader(source.header, source.inheritedEventCount),
@@ -156,6 +199,7 @@ export class SessionHistoryController {
         projections: source.projections === undefined
           ? { asOfSeq: cursor, values: {} }
           : projectionBlock(source.projections),
+        ...assistantStream === undefined ? {} : { assistantStream },
       }
       if (address.kind === 'session' && source.source === 'prepared') {
         const promotion = source.retain()
@@ -173,19 +217,26 @@ export class SessionHistoryController {
           await new Promise<void>((resolve) => { wake = resolve })
           continue
         }
+        if (item.type === 'assistant-stream') {
+          if (item.ordinal > assistantStreamOrdinalCut) {
+            yield { type: 'assistant-stream', frame: item.frame }
+          }
+          continue
+        }
         const expectedSeq = SessionSeq(nextOffset)
-        if (item.seq < expectedSeq) continue
-        if (item.seq !== expectedSeq) {
+        if (item.event.seq < expectedSeq) continue
+        if (item.event.seq !== expectedSeq) {
           throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {})
         }
         nextOffset = SessionLogOffset(nextOffset + 1)
-        yield entryFor(item)
+        yield entryFor(item.event)
       }
     } finally {
       this.closeFollowers.delete(close)
       signal.removeEventListener('abort', onAbort)
       disposeCreated()
       disposeEvent()
+      disposeAssistantStream?.()
     }
   }
 
@@ -225,6 +276,14 @@ export class SessionHistoryController {
 
 }
 
+function wireAssistantStreamFrame(frame: AssistantStreamFrame): SessionAssistantStreamFrame {
+  if (frame.type !== 'chunk') return frame
+  return {
+    ...frame,
+    chunk: frame.chunk as JsonValue,
+  }
+}
+
 function projectionBlock(
   snapshot: NonNullable<SessionObservation['projections']>,
 ): SessionProjectionBaseline {

+ 52 - 2
packages/api/session-controller/src/types.ts

@@ -4,7 +4,7 @@ import type {
   AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
 } from '@deepseek-ai/dsh-attachment'
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -446,15 +446,63 @@ export interface SessionPageRequest {
 export interface SessionFollowRequest {
   readonly address: SessionAddress
   readonly maxMessages?: number
+  /** Include process-local assistant presentation frames for the Web client. */
+  readonly assistantStream?: true
 }
 
+/** One active assistant attempt in a reconnect opening snapshot. */
+export interface SessionAssistantStreamAttempt {
+  readonly attemptId: LlmAttemptId
+  /** Safe-integer wall-clock time copied from the attempt's start frame. */
+  readonly startedTime: number
+  readonly turn: number
+  readonly step: number
+  readonly chunks: readonly JsonValue[]
+  /** Exact durable v1 chunk records already represented by {@link chunks}. */
+  readonly legacyChunkSeqs: readonly number[]
+}
+
+/** Complete process-local assistant state at one follow opening. */
+export interface SessionAssistantStreamBaseline {
+  readonly revision: number
+  readonly attempts: readonly SessionAssistantStreamAttempt[]
+}
+
+/** Browser wire form of one process-local assistant frame. */
+export type SessionAssistantStreamFrame =
+  | {
+    readonly type: 'start'
+    readonly attemptId: LlmAttemptId
+    readonly revision: number
+    readonly startedTime: number
+    readonly turn: number
+    readonly step: number
+  }
+  | {
+    readonly type: 'chunk'
+    readonly attemptId: LlmAttemptId
+    readonly revision: number
+    readonly index: number
+    readonly chunk: JsonValue
+    readonly legacyChunkSeq: number
+  }
+  | {
+    readonly type: 'end'
+    readonly attemptId: LlmAttemptId
+    readonly revision: number
+    /** Number of chunk frames represented by this terminal marker. */
+    readonly index: number
+    readonly outcome: 'committed' | 'aborted'
+    readonly legacyChunkSeqs: readonly number[]
+  }
+
 /** One contiguous backwards page of a Session log. */
 export interface SessionPage {
   readonly records: readonly SessionHistoryRecord[]
   readonly hasMore: boolean
 }
 
-/** Complete opening window followed by ordered events appended after its cursor. */
+/** Complete opening window followed by ordered durable events and opted-in assistant frames. */
 export type SessionFollowFrame =
   | {
     readonly type: 'snapshot'
@@ -463,8 +511,10 @@ export type SessionFollowFrame =
     readonly records: readonly SessionHistoryRecord[]
     readonly hasMore: boolean
     readonly projections: SessionProjectionBaseline
+    readonly assistantStream?: SessionAssistantStreamBaseline
   }
   | SessionEventEntry
+  | { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
 
 /** One pending inbox occurrence in the authoritative queue snapshot. */
 export interface SessionQueuedItem {

+ 9 - 1
packages/api/session-controller/tests/fake-api.client.ts

@@ -9,6 +9,7 @@ import type {
 } from '@deepseek-ai/dsh-api-remotes/client'
 import type {
   SessionAddress,
+  SessionAssistantStreamBaseline,
   SessionControlBaseline,
   SessionControlFrame,
   SessionFollowFrame,
@@ -158,6 +159,10 @@ export class FakeApiClient {
     jobs: {},
     projections: {},
   }
+  assistantStreamBaseline: SessionAssistantStreamBaseline = {
+    revision: 0,
+    attempts: [],
+  }
   workspaceBaseline: Extract<WorkspaceFollowFrame, { type: 'baseline' }>['value'] = {
     items: [],
     archivedSessionIds: [],
@@ -276,7 +281,7 @@ export class FakeApiClient {
   /** Push one live Session event to every follower of that Session. */
   async pushFollow(
     sessionId: SessionId,
-    frame: Extract<SessionFollowFrame, { type: 'event' }>,
+    frame: Exclude<SessionFollowFrame, { type: 'snapshot' }>,
   ): Promise<void> {
     await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise<void>((resolve) => {
       conn.feed({ kind: 'frame', value: frame, delivered: resolve })
@@ -399,6 +404,9 @@ export class FakeApiClient {
         records: page.records.filter(record => historyRecordLastSeq(record) <= cursor),
         hasMore: page.hasMore,
         projections: page.projections ?? { asOfSeq: cursor, values: {} },
+        ...request.assistantStream === true
+          ? { assistantStream: this.assistantStreamBaseline }
+          : {},
       }
       yield* stream.values
     } finally {

+ 1 - 0
packages/api/session-controller/tests/manager.client.spec.ts

@@ -310,6 +310,7 @@ describe('subagent catalogs', () => {
         address: {
           kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
         },
+        assistantStream: true,
         maxMessages: 50,
       },
     ])

+ 501 - 2
packages/api/session-controller/tests/session-history-journal.host.spec.ts

@@ -2,10 +2,10 @@
 
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
 import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session'
 import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
-import { ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import type {
@@ -83,6 +83,17 @@ async function openFollow(
   return { [Symbol.asyncIterator]: () => iterator }
 }
 
+/** Abort one follow and await both its iterator and owning Context teardown. */
+async function disposeFollow(
+  ctx: Context,
+  iterator: AsyncIterator<SessionFollowFrame>,
+  abort: AbortController,
+): Promise<void> {
+  abort.abort()
+  await iterator.return?.()
+  await ctx.fiber.dispose()
+}
+
 /** Expand packed page records for assertions over the logical journal. */
 function pageEvents(page: SessionPage): SessionWireEvent[] {
   return page.records.flatMap(record => record.type === 'event'
@@ -102,6 +113,494 @@ function chunkRow(event: ChunkRowEvent): ChunkRow {
 }
 
 describe('Session history raw journal', () => {
+  it('opens an opted-in assistant baseline and preserves mixed live FIFO order', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const attemptId = LlmAttemptId('live-follow-attempt')
+    const emit = (frame: AssistantStreamFrame): void => {
+      ctx.emit('agent/assistant-stream', { agent, frame })
+    }
+    emit({
+      type: 'start', attemptId, revision: 1, startedTime: 100,
+      turn: 1, step: 1,
+    })
+    const firstChunk = session.append('assistant/chunk', {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' },
+    })
+    emit({
+      type: 'chunk', attemptId, revision: 2, index: 0,
+      chunk: firstChunk.data.chunk, legacyChunkSeq: firstChunk.seq,
+    })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+
+    await expect(iterator.next()).resolves.toMatchObject({
+      done: false,
+      value: {
+        type: 'snapshot',
+        assistantStream: {
+          revision: 2,
+          attempts: [{
+            attemptId,
+            startedTime: 100,
+            turn: 1,
+            step: 1,
+            chunks: [firstChunk.data.chunk],
+            legacyChunkSeqs: [firstChunk.seq],
+          }],
+        },
+      },
+    })
+    const nextChunk = session.append('assistant/chunk', {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' },
+    })
+    const nextFrame: AssistantStreamFrame = {
+      type: 'chunk', attemptId, revision: 3, index: 1,
+      chunk: nextChunk.data.chunk, legacyChunkSeq: nextChunk.seq,
+    }
+    emit(nextFrame)
+    const message = appendAssistantText(session, 'ab', 1)
+    const endFrame: AssistantStreamFrame = {
+      type: 'end', attemptId, revision: 4, index: 2, outcome: 'committed',
+      legacyChunkSeqs: [firstChunk.seq, nextChunk.seq],
+    }
+    emit(endFrame)
+
+    await expect(iterator.next()).resolves.toEqual({
+      done: false, value: { type: 'event', event: nextChunk },
+    })
+    await expect(iterator.next()).resolves.toEqual({
+      done: false, value: { type: 'assistant-stream', frame: nextFrame },
+    })
+    await expect(iterator.next()).resolves.toEqual({
+      done: false, value: { type: 'event', event: message },
+    })
+    await expect(iterator.next()).resolves.toEqual({
+      done: false, value: { type: 'assistant-stream', frame: endFrame },
+    })
+    abort.abort()
+    await iterator.next()
+    await ctx.fiber.dispose()
+  })
+
+  it('forwards revision one when the attached Agent lifecycle restarts after opening', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const attemptId = LlmAttemptId(`${session.id}:1`)
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'start', attemptId, revision: 1, startedTime: 100,
+        turn: 1, step: 1,
+      },
+    })
+    const oldChunk = session.append('assistant/chunk', {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'old' },
+    })
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'chunk', attemptId, revision: 2, index: 0,
+        chunk: oldChunk.data.chunk, legacyChunkSeq: oldChunk.seq,
+      },
+    })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+
+    try {
+      await expect(iterator.next()).resolves.toMatchObject({
+        done: false,
+        value: {
+          type: 'snapshot',
+          assistantStream: {
+            revision: 2,
+            attempts: [{
+              attemptId,
+              startedTime: 100,
+              turn: 1,
+              step: 1,
+              chunks: [oldChunk.data.chunk],
+              legacyChunkSeqs: [oldChunk.seq],
+            }],
+          },
+        },
+      })
+
+      ctx.emit('agent/disposed', { agent })
+      const replacementAgent = { id: session.id, session, status: 'running', ctx } as Agent
+      const replacement: AssistantStreamFrame = {
+        type: 'start', attemptId, revision: 1, startedTime: 200,
+        turn: 2, step: 1,
+      }
+      ctx.emit('agent/assistant-stream', { agent: replacementAgent, frame: replacement })
+      await expect(iterator.next()).resolves.toEqual({
+        done: false,
+        value: { type: 'assistant-stream', frame: replacement },
+      })
+    } finally {
+      await disposeFollow(ctx, iterator, abort)
+    }
+  })
+
+  it('publishes an empty replacement baseline after an Agent frame revision gap', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const attemptId = LlmAttemptId('revision-gap-attempt')
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'start', attemptId, revision: 1, startedTime: 100,
+        turn: 1, step: 1,
+      },
+    })
+    const chunk = session.append('assistant/chunk', {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'after gap' },
+    })
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'chunk', attemptId, revision: 3, index: 0,
+        chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
+      },
+    })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+
+    try {
+      await expect(iterator.next()).resolves.toMatchObject({
+        done: false,
+        value: {
+          type: 'snapshot',
+          assistantStream: { revision: 3, attempts: [] },
+        },
+      })
+    } finally {
+      await disposeFollow(ctx, iterator, abort)
+    }
+  })
+
+  it('drops active attempts when an Agent chunk index is not dense', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const attemptId = LlmAttemptId('dense-index-attempt')
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'start', attemptId, revision: 1, startedTime: 100,
+        turn: 1, step: 1,
+      },
+    })
+    const chunk = session.append('assistant/chunk', {
+      turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'out of order' },
+    })
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'chunk', attemptId, revision: 2, index: 1,
+        chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
+      },
+    })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+
+    try {
+      await expect(iterator.next()).resolves.toMatchObject({
+        done: false,
+        value: {
+          type: 'snapshot',
+          assistantStream: { revision: 2, attempts: [] },
+        },
+      })
+    } finally {
+      await disposeFollow(ctx, iterator, abort)
+    }
+  })
+
+  it('reuses an unchanged Assistant baseline across follow openings', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const attemptId = LlmAttemptId('cached-baseline-attempt')
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'start', attemptId, revision: 1, startedTime: 100,
+        turn: 1, step: 1,
+      },
+    })
+
+    const firstAbort = new AbortController()
+    const firstIterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, firstAbort.signal)[Symbol.asyncIterator]()
+    const secondAbort = new AbortController()
+    const secondIterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, secondAbort.signal)[Symbol.asyncIterator]()
+    try {
+      const first = await firstIterator.next()
+      if (first.done || first.value.type !== 'snapshot') throw new Error('first follow did not open')
+      const baseline = first.value.assistantStream
+      expect(baseline).toMatchObject({ revision: 1, attempts: [{ attemptId }] })
+      const second = await secondIterator.next()
+      if (second.done || second.value.type !== 'snapshot') throw new Error('second follow did not open')
+      expect(second.value.assistantStream).toEqual(baseline)
+    } finally {
+      firstAbort.abort()
+      secondAbort.abort()
+      await firstIterator.return?.()
+      await secondIterator.return?.()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('opens an empty Assistant baseline before the target Agent emits frames', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+
+    try {
+      await expect(iterator.next()).resolves.toMatchObject({
+        done: false,
+        value: {
+          type: 'snapshot',
+          assistantStream: { revision: 0, attempts: [] },
+        },
+      })
+    } finally {
+      await disposeFollow(ctx, iterator, abort)
+    }
+  })
+
+  it('filters Assistant frames from another Session out of the target follow', async () => {
+    const { ctx } = await harness()
+    const target = ctx.sessions.create(undefined, { meta: { cwd: '/target' } })
+    const other = ctx.sessions.create(undefined, { meta: { cwd: '/other' } })
+    const otherAgent = { id: other.id, session: other, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: target.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+    try {
+      await expect(iterator.next()).resolves.toMatchObject({
+        done: false,
+        value: { type: 'snapshot' },
+      })
+
+      ctx.emit('agent/assistant-stream', {
+        agent: otherAgent,
+        frame: {
+          type: 'start', attemptId: LlmAttemptId('other-session-attempt'),
+          revision: 1, startedTime: 100, turn: 1, step: 1,
+        },
+      })
+      const targetEvent = target.append('turn/start', { turn: 1 })
+      await expect(iterator.next()).resolves.toEqual({
+        done: false,
+        value: { type: 'event', event: targetEvent },
+      })
+    } finally {
+      await disposeFollow(ctx, iterator, abort)
+    }
+  })
+
+  it('does not replay a buffered Assistant frame already represented by the opening baseline', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const observationStarted = Promise.withResolvers<undefined>()
+    const releaseObservation = Promise.withResolvers<undefined>()
+    const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
+    const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (sessionId, options) => {
+      observationStarted.resolve(undefined)
+      await releaseObservation.promise
+      return await originalObserve(sessionId, options)
+    })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+
+    try {
+      const opening = iterator.next()
+      await observationStarted.promise
+      const frame: AssistantStreamFrame = {
+        type: 'start', attemptId: LlmAttemptId('opening-cut-attempt'),
+        revision: 1, startedTime: 100, turn: 1, step: 1,
+      }
+      ctx.emit('agent/assistant-stream', { agent, frame })
+      releaseObservation.resolve(undefined)
+      await expect(opening).resolves.toMatchObject({
+        done: false,
+        value: {
+          type: 'snapshot',
+          assistantStream: { revision: 1, attempts: [{ attemptId: frame.attemptId }] },
+        },
+      })
+
+      const durable = session.append('turn/start', { turn: 1 })
+      await expect(iterator.next()).resolves.toEqual({
+        done: false,
+        value: { type: 'event', event: durable },
+      })
+    } finally {
+      releaseObservation.resolve(undefined)
+      observe.mockRestore()
+      abort.abort()
+      await iterator.return?.()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('does not release an old-lifecycle frame after the opening baseline resets to revision one', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const attemptId = LlmAttemptId(`${session.id}:1`)
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'start', attemptId, revision: 1, startedTime: 100,
+        turn: 1, step: 1,
+      },
+    })
+    const observationStarted = Promise.withResolvers<undefined>()
+    const releaseObservation = Promise.withResolvers<undefined>()
+    const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
+    const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (sessionId, options) => {
+      observationStarted.resolve(undefined)
+      await releaseObservation.promise
+      return await originalObserve(sessionId, options)
+    })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+      assistantStream: true,
+    }, abort.signal)[Symbol.asyncIterator]()
+
+    try {
+      const opening = iterator.next()
+      await observationStarted.promise
+      const oldChunk = session.append('assistant/chunk', {
+        turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'old lifecycle' },
+      })
+      ctx.emit('agent/assistant-stream', {
+        agent,
+        frame: {
+          type: 'chunk', attemptId, revision: 2, index: 0,
+          chunk: oldChunk.data.chunk, legacyChunkSeq: oldChunk.seq,
+        },
+      })
+      ctx.emit('agent/assistant-stream', {
+        agent,
+        frame: {
+          type: 'start', attemptId, revision: 1, startedTime: 200,
+          turn: 2, step: 1,
+        },
+      })
+      releaseObservation.resolve(undefined)
+      await expect(opening).resolves.toMatchObject({
+        done: false,
+        value: {
+          type: 'snapshot',
+          assistantStream: {
+            revision: 1,
+            attempts: [{ attemptId, startedTime: 200, turn: 2, step: 1, chunks: [] }],
+          },
+        },
+      })
+
+      const durable = session.append('turn/start', { turn: 2 })
+      await expect(iterator.next()).resolves.toEqual({
+        done: false,
+        value: { type: 'event', event: durable },
+      })
+    } finally {
+      releaseObservation.resolve(undefined)
+      observe.mockRestore()
+      abort.abort()
+      await iterator.return?.()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('keeps assistant frames out of a durable-only follower', async () => {
+    const { ctx } = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+    const agent = { id: session.id, session, status: 'running', ctx } as Agent
+    const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+    const abort = new AbortController()
+    const iterator = history.follow({
+      address: { kind: 'session', sessionId: session.id },
+    }, abort.signal)[Symbol.asyncIterator]()
+    const opening = await iterator.next()
+    expect(opening.value).not.toHaveProperty('assistantStream')
+    const attemptId = LlmAttemptId('durable-only-attempt')
+
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'start', attemptId, revision: 1, startedTime: 200,
+        turn: 1, step: 1,
+      },
+    })
+    const durable = session.append('turn/start', { turn: 1 })
+    ctx.emit('agent/assistant-stream', {
+      agent,
+      frame: {
+        type: 'end', attemptId, revision: 2, index: 0,
+        outcome: 'aborted', legacyChunkSeqs: [],
+      },
+    })
+    const next = session.append('turn/end', {
+      turn: 1, reason: { kind: 'completed' },
+    })
+
+    await expect(iterator.next()).resolves.toEqual({
+      done: false, value: { type: 'event', event: durable },
+    })
+    await expect(iterator.next()).resolves.toEqual({
+      done: false, value: { type: 'event', event: next },
+    })
+    abort.abort()
+    await iterator.next()
+    await ctx.fiber.dispose()
+  })
+
+
   it('follows raw tool events and preserves result metadata without a Tools service', async () => {
     const { ctx } = await harness()
     const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })

+ 2 - 0
packages/api/session-controller/tests/session.client.spec.ts

@@ -433,6 +433,7 @@ describe('prompt and cancel errors', () => {
         address: {
           kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
         },
+        assistantStream: true,
         maxMessages: 50,
       },
     ])
@@ -527,6 +528,7 @@ describe('prompt and cancel errors', () => {
         address: {
           kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot',
         },
+        assistantStream: true,
         maxMessages: 50,
       },
     ])

+ 311 - 0
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -10,6 +10,8 @@ import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
+import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
 import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
 import { scopeOf } from '../src/client/scope.ts'
 import type { SessionFollowFrame } from '../src/types.ts'
@@ -132,6 +134,313 @@ describe('search', () => {
 })
 
 describe('scope tree', () => {
+  it('publishes live assistant chunks and durable settlement atomically through one event source', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    b.svc.open(sid('s1'))
+    const binding = b.svc.binding(sid('s1'))
+    if (binding === undefined) throw new Error('expected Session binding')
+    await vi.waitFor(() => {
+      expect(binding.session.getSnapshot().openState).toBe('open')
+    })
+    const attemptId = LlmAttemptId('web-live-attempt')
+    const durableChunk = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/chunk', seq: 0, time: 1,
+        data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live' } },
+      },
+    }
+    const durableMessage = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/message', seq: 1, time: 2,
+        data: {
+          turn: 1,
+          step: 1,
+          message: {
+            role: 'assistant',
+            content: [{ type: 'text', text: 'live' }],
+            source: { kind: 'model', provider: 'p', model: 'm' },
+            id: 'message-1',
+          },
+        },
+        sourceEventSeqs: [0],
+        surfaceOp: 'append' as const,
+      },
+    }
+    const publications: string[][] = []
+    const dispose = binding.eventSource.subscribe(() => {
+      publications.push(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
+    })
+
+    await b.api.pushFollow(sid('s1'), {
+      type: 'assistant-stream',
+      frame: {
+        type: 'start', attemptId, revision: 1, startedTime: 1,
+        turn: 1, step: 1,
+      },
+    })
+    await b.api.pushFollow(sid('s1'), durableChunk)
+    await Promise.resolve()
+    expect(binding.eventSource.getSnapshot().entries).toEqual([])
+
+    await b.api.pushFollow(sid('s1'), {
+      type: 'assistant-stream',
+      frame: {
+        type: 'chunk', attemptId, revision: 2, index: 0,
+        chunk: durableChunk.event.data.chunk,
+        legacyChunkSeq: 0,
+      },
+    })
+    await vi.waitFor(() => {
+      expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
+    })
+    await b.api.pushFollow(sid('s1'), durableMessage)
+    await Promise.resolve()
+    expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
+
+    await b.api.pushFollow(sid('s1'), {
+      type: 'assistant-stream',
+      frame: {
+        type: 'end', attemptId, revision: 3, index: 1,
+        outcome: 'committed',
+        legacyChunkSeqs: [0],
+      },
+    })
+    await vi.waitFor(() => {
+      expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
+    })
+
+    expect(publications).toEqual([
+      ['assistant/chunk'],
+      ['assistant/chunk', 'assistant/message'],
+    ])
+    dispose()
+  })
+
+  it('replaces an active assistant baseline on reconnect without duplicate chunks', async () => {
+    const b = bench()
+    const attemptId = LlmAttemptId('reconnect-attempt')
+    const first = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/chunk', seq: 0, time: 1,
+        data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } },
+      },
+    }
+    const second = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/chunk', seq: 1, time: 2,
+        data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } },
+      },
+    }
+    let records = [first] as never[]
+    b.api.onHistory = () => Promise.resolve(ok({ records, hasMore: false }))
+    b.api.assistantStreamBaseline = {
+      revision: 2,
+      attempts: [{
+        attemptId, startedTime: 1, turn: 1, step: 1,
+        chunks: [first.event.data.chunk], legacyChunkSeqs: [0],
+      }],
+    }
+    await feedList(b, [{ id: 's1' }])
+    b.svc.open(sid('s1'))
+    const binding = b.svc.binding(sid('s1'))
+    if (binding === undefined) throw new Error('expected Session binding')
+    await vi.waitFor(() => {
+      expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
+    })
+
+    records = [first, second] as never[]
+    b.api.assistantStreamBaseline = {
+      revision: 3,
+      attempts: [{
+        attemptId, startedTime: 1, turn: 1, step: 1,
+        chunks: [first.event.data.chunk, second.event.data.chunk],
+        legacyChunkSeqs: [0, 1],
+      }],
+    }
+    b.api.failStreams(new RemoteStreamCarrierError('lost'))
+    await vi.waitFor(() => {
+      expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
+      expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
+    })
+
+    expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1])
+  })
+
+  it('stages a reconnect-tail assistant settlement behind its exact active attempt', async () => {
+    const b = bench()
+    const attemptId = LlmAttemptId('reconnect-settlement-attempt')
+    const priorChunk = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/chunk', seq: 0, time: 10,
+        data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'retry ' } },
+      },
+    }
+    const priorMessage = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/message', seq: 1, time: 11,
+        data: {
+          turn: 1,
+          step: 1,
+          message: {
+            role: 'assistant',
+            content: [{ type: 'text', text: 'retry ' }],
+            source: { kind: 'model', provider: 'p', model: 'm' },
+            id: 'prior-attempt-message',
+          },
+        },
+        sourceEventSeqs: [0],
+        surfaceOp: 'append' as const,
+      },
+    }
+    const currentChunk = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/chunk', seq: 2, time: 20,
+        data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'settled' } },
+      },
+    }
+    const currentMessage = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/message', seq: 3, time: 21,
+        data: {
+          turn: 1,
+          step: 1,
+          message: {
+            role: 'assistant',
+            content: [{ type: 'text', text: 'settled' }],
+            source: { kind: 'model', provider: 'p', model: 'm' },
+            id: 'current-attempt-message',
+          },
+        },
+        sourceEventSeqs: [2],
+        surfaceOp: 'append' as const,
+      },
+    }
+    b.api.onHistory = () => Promise.resolve(ok({
+      records: [priorChunk, priorMessage, currentChunk, currentMessage] as never[],
+      hasMore: false,
+    }))
+    b.api.assistantStreamBaseline = {
+      revision: 2,
+      attempts: [{
+        attemptId,
+        startedTime: 20,
+        turn: 1,
+        step: 1,
+        chunks: [currentChunk.event.data.chunk],
+        legacyChunkSeqs: [2],
+      }],
+    }
+    await feedList(b, [{ id: 's1' }])
+    b.svc.open(sid('s1'))
+    const binding = b.svc.binding(sid('s1'))
+    if (binding === undefined) throw new Error('expected Session binding')
+    await vi.waitFor(() => {
+      expect(binding.session.getSnapshot().openState).toBe('open')
+    })
+
+    expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2])
+    expect(binding.eventSource.getSnapshot().entries.at(1)?.event).toBe(priorMessage.event)
+    expect(binding.eventSource.getSnapshot().entries.at(-1)?.event).toBe(currentChunk.event)
+
+    await b.api.pushFollow(sid('s1'), {
+      type: 'assistant-stream',
+      frame: {
+        type: 'end', attemptId, revision: 3, index: 1,
+        outcome: 'committed', legacyChunkSeqs: [2],
+      },
+    })
+    await vi.waitFor(() => {
+      expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2, 3])
+    })
+    expect(binding.eventSource.getSnapshot().change).toEqual({
+      kind: 'append', entries: [currentMessage],
+    })
+  })
+
+  it('rebaselines a reconnect settlement whose end index skips the active tail', async () => {
+    const b = bench()
+    const attemptId = LlmAttemptId('reconnect-end-index-attempt')
+    const chunk = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/chunk', seq: 0, time: 20,
+        data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'settled' } },
+      },
+    }
+    const message = {
+      type: 'event' as const,
+      event: {
+        type: 'assistant/message', seq: 1, time: 21,
+        data: {
+          turn: 1,
+          step: 1,
+          message: {
+            role: 'assistant',
+            content: [{ type: 'text', text: 'settled' }],
+            source: { kind: 'model', provider: 'p', model: 'm' },
+            id: 'current-attempt-message',
+          },
+        },
+        sourceEventSeqs: [0],
+        surfaceOp: 'append' as const,
+      },
+    }
+    b.api.onHistory = () => Promise.resolve(ok({
+      records: [chunk, message] as never[],
+      hasMore: false,
+    }))
+    b.api.assistantStreamBaseline = {
+      revision: 2,
+      attempts: [{
+        attemptId,
+        startedTime: 20,
+        turn: 1,
+        step: 1,
+        chunks: [chunk.event.data.chunk],
+        legacyChunkSeqs: [0],
+      }],
+    }
+    await feedList(b, [{ id: 's1' }])
+    b.svc.open(sid('s1'))
+    const binding = b.svc.binding(sid('s1'))
+    if (binding === undefined) throw new Error('expected Session binding')
+    await vi.waitFor(() => {
+      expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
+    })
+
+    await b.api.pushFollow(sid('s1'), {
+      type: 'assistant-stream',
+      frame: {
+        type: 'end', attemptId, revision: 3, index: 0,
+        outcome: 'committed', legacyChunkSeqs: [0],
+      },
+    })
+    await vi.waitFor(() => {
+      expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
+    })
+    expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
+
+    await b.api.pushFollow(sid('s1'), {
+      type: 'assistant-stream',
+      frame: {
+        type: 'end', attemptId, revision: 3, index: 1,
+        outcome: 'committed', legacyChunkSeqs: [0],
+      },
+    })
+    await vi.waitFor(() => {
+      expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1])
+    })
+  })
+
   it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => {
     const b = bench()
     const scoped = b.svc.resolveAgentScope(sid('s-early'))
@@ -278,6 +587,7 @@ describe('Agent scope disposal lifecycle', () => {
                       records: [],
                       hasMore: false,
                       projections: { asOfSeq: -1, values: {} },
+                      assistantStream: { revision: 0, attempts: [] },
                     } as const,
                   })
                 }
@@ -347,6 +657,7 @@ describe('Agent scope disposal lifecycle', () => {
                       records: [],
                       hasMore: false,
                       projections: { asOfSeq: -1, values: {} },
+                      assistantStream: { revision: 0, attempts: [] },
                     } as const,
                   })
                 }

+ 218 - 4
packages/api/session-controller/tests/transport.client.spec.ts

@@ -6,6 +6,7 @@ import {
   type RemoteStreamOptions,
 } from '@deepseek-ai/dsh-api-gateway/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import {
   createSessionControlStream,
@@ -16,6 +17,8 @@ import {
 import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
 import type {
   SessionAddress,
+  SessionAssistantStreamBaseline,
+  SessionAssistantStreamFrame,
   SessionControlFrame,
   SessionEventEntry,
   SessionFollowFrame,
@@ -59,6 +62,7 @@ function snapshot(
   cursor: number,
   records: readonly SessionHistoryRecord[],
   hasMore = false,
+  assistantStream: SessionAssistantStreamBaseline = { revision: 0, attempts: [] },
 ): SessionFollowFrame {
   return {
     type: 'snapshot',
@@ -71,9 +75,14 @@ function snapshot(
     records,
     hasMore,
     projections: { asOfSeq: cursor, values: {} },
+    assistantStream,
   }
 }
 
+function assistantFrame(frame: SessionAssistantStreamFrame): SessionFollowFrame {
+  return { type: 'assistant-stream', frame }
+}
+
 function sessionClient(remote: SessionTransportRemote): SessionRemotes {
   return {
     session: remote as SessionRemote,
@@ -141,6 +150,206 @@ class ScriptedSessionRemote implements SessionTransportRemote {
 }
 
 describe('Session Client stream adapters', () => {
+  it('opts into assistant notifications and publishes the reconnect baseline plus live frame', async () => {
+    const attemptId = LlmAttemptId('transport-attempt')
+    const baseline: SessionAssistantStreamBaseline = {
+      revision: 2,
+      attempts: [{
+        attemptId,
+        startedTime: 1,
+        turn: 1,
+        step: 1,
+        chunks: [{ type: 'text-delta', index: 0, text: 'a' }],
+        legacyChunkSeqs: [0],
+      }],
+    }
+    const frame: SessionAssistantStreamFrame = {
+      type: 'chunk', attemptId, revision: 3, index: 1,
+      chunk: { type: 'text-delta', index: 0, text: 'b' }, legacyChunkSeq: 1,
+    }
+    const remote = new ScriptedSessionRemote(
+      [{ frames: [snapshot(0, [entry(0)], false, baseline), assistantFrame(frame)], hold: true }],
+      [],
+    )
+    const changes: SessionJournalChange[] = []
+    const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
+      publish: (change) => { changes.push(change) },
+      failed: vi.fn(),
+    })
+
+    await stream.open({})
+    await vi.waitFor(() => { expect(changes).toHaveLength(2) })
+
+    expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }])
+    expect(changes).toMatchObject([
+      { type: 'replace', page: { assistantStream: baseline } },
+      { type: 'assistant-stream', frame },
+    ])
+    await stream.dispose()
+  })
+
+  it('rejects an opted-in opening that omits its Assistant baseline', async () => {
+    const remote = new ScriptedSessionRemote([{
+      frames: [{
+        type: 'snapshot',
+        header: {
+          version: 1,
+          id: ADDRESS.sessionId,
+          createdAt: 0,
+        },
+        cursor: -1,
+        records: [],
+        hasMore: false,
+        projections: { asOfSeq: -1, values: {} },
+      }],
+    }], [])
+    const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
+      publish: vi.fn(),
+      failed: vi.fn(),
+    })
+
+    try {
+      await expect(stream.open({})).rejects.toMatchObject({
+        code: 'gateway/internal',
+        message: 'session assistant stream omitted its opted-in opening baseline',
+      })
+    } finally {
+      await stream.dispose()
+    }
+  })
+
+  it('rejects an Assistant frame that arrives before the opening baseline', async () => {
+    const remote = new ScriptedSessionRemote([{
+      frames: [assistantFrame({
+        type: 'start', attemptId: LlmAttemptId('pre-opening-attempt'),
+        revision: 1, startedTime: 1, turn: 1, step: 1,
+      })],
+    }], [])
+    const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
+      publish: vi.fn(),
+      failed: vi.fn(),
+    })
+
+    try {
+      await expect(stream.open({})).rejects.toMatchObject({
+        code: 'gateway/internal',
+        message: 'session event stream emitted an entry before its opening cursor',
+      })
+      expect(remote.followRequests).toEqual([{ address: ADDRESS, assistantStream: true }])
+    } finally {
+      await stream.dispose()
+    }
+  })
+
+  it('rebaselines after a transient assistant revision gap without advancing the durable cursor', async () => {
+    const attemptId = LlmAttemptId('gapped-attempt')
+    const start: SessionAssistantStreamFrame = {
+      type: 'start', attemptId, revision: 1, startedTime: 1,
+      turn: 1, step: 1,
+    }
+    const gap: SessionAssistantStreamFrame = {
+      type: 'chunk', attemptId, revision: 3, index: 0,
+      chunk: { type: 'text-delta', index: 0, text: 'lost predecessor' },
+      legacyChunkSeq: 1,
+    }
+    const replacement: SessionAssistantStreamBaseline = {
+      revision: 3,
+      attempts: [{
+        attemptId,
+        startedTime: 1,
+        turn: 1,
+        step: 1,
+        chunks: [gap.chunk],
+        legacyChunkSeqs: [1],
+      }],
+    }
+    const remote = new ScriptedSessionRemote([
+      {
+        frames: [snapshot(0, [entry(0)]), assistantFrame(start), assistantFrame(gap)],
+      },
+      { frames: [snapshot(0, [entry(0)], false, replacement)], hold: true },
+    ], [])
+    const changes: SessionJournalChange[] = []
+    const carrierFailed = vi.fn()
+    const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
+      publish: (change) => { changes.push(change) },
+      carrierFailed,
+      failed: vi.fn(),
+    })
+
+    await stream.open({})
+    await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
+
+    expect(changes.map(change => change.type)).toEqual([
+      'replace', 'assistant-stream', 'replace',
+    ])
+    expect(changes.at(-1)).toMatchObject({
+      type: 'replace', page: { assistantStream: replacement },
+    })
+    expect(remote.pageRequests).toEqual([])
+    expect(carrierFailed).toHaveBeenCalledWith(expect.objectContaining({
+      message: 'session assistant stream skipped revision 2',
+    }))
+    await stream.dispose()
+  })
+
+  it('rebaselines when a replacement Agent lifecycle restarts at revision one', async () => {
+    const attemptId = LlmAttemptId('replacement-lifecycle-attempt')
+    const previous: SessionAssistantStreamBaseline = {
+      revision: 2,
+      attempts: [{
+        attemptId,
+        startedTime: 1,
+        turn: 1,
+        step: 1,
+        chunks: [{ type: 'text-delta', index: 0, text: 'old' }],
+        legacyChunkSeqs: [0],
+      }],
+    }
+    const replacementStart: SessionAssistantStreamFrame = {
+      type: 'start', attemptId, revision: 1, startedTime: 2,
+      turn: 2, step: 1,
+    }
+    const replacement: SessionAssistantStreamBaseline = {
+      revision: 1,
+      attempts: [{
+        attemptId,
+        startedTime: 2,
+        turn: 2,
+        step: 1,
+        chunks: [],
+        legacyChunkSeqs: [],
+      }],
+    }
+    const remote = new ScriptedSessionRemote([
+      {
+        frames: [snapshot(0, [entry(0)], false, previous), assistantFrame(replacementStart)],
+      },
+      { frames: [snapshot(0, [entry(0)], false, replacement)], hold: true },
+    ], [])
+    const changes: SessionJournalChange[] = []
+    const carrierFailed = vi.fn()
+    const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
+      publish: (change) => { changes.push(change) },
+      carrierFailed,
+      failed: vi.fn(),
+    })
+
+    try {
+      await stream.open({})
+      await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
+      expect(changes).toMatchObject([
+        { type: 'replace', page: { assistantStream: previous } },
+        { type: 'replace', page: { assistantStream: replacement } },
+      ])
+      expect(carrierFailed).toHaveBeenCalledWith(expect.objectContaining({
+        message: 'session assistant stream skipped revision 3',
+      }))
+    } finally {
+      await stream.dispose()
+    }
+  })
+
   it('validates a packed logical range before publishing one compact Client entry', async () => {
     const row = chunks(1)
     const remote = new ScriptedSessionRemote(
@@ -215,7 +424,9 @@ describe('Session Client stream adapters', () => {
     await vi.waitFor(() => { expect(changes).toHaveLength(2) })
     await stream.prepend({ beforeSeq: 2, maxMessages: 50 })
 
-    expect(remote.followRequests).toEqual([{ address: ADDRESS, maxMessages: 50 }])
+    expect(remote.followRequests).toEqual([{
+      address: ADDRESS, assistantStream: true, maxMessages: 50,
+    }])
     expect(remote.pageRequests).toEqual([
       { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 },
     ])
@@ -252,8 +463,8 @@ describe('Session Client stream adapters', () => {
     await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
 
     expect(remote.followRequests).toEqual([
-      { address: ADDRESS, maxMessages: 50 },
-      { address: ADDRESS, maxMessages: 50 },
+      { address: ADDRESS, assistantStream: true, maxMessages: 50 },
+      { address: ADDRESS, assistantStream: true, maxMessages: 50 },
     ])
     expect(remote.pageRequests).toEqual([])
     expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
@@ -282,7 +493,10 @@ describe('Session Client stream adapters', () => {
     await stream.open({})
     finish.resolve(undefined)
     await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
-    expect(remote.followRequests).toEqual([{ address: ADDRESS }, { address: ADDRESS }])
+    expect(remote.followRequests).toEqual([
+      { address: ADDRESS, assistantStream: true },
+      { address: ADDRESS, assistantStream: true },
+    ])
     expect(remote.pageRequests).toEqual([])
     await stream.dispose()
   })

+ 1 - 0
packages/api/session-controller/tsconfig.host.json

@@ -10,6 +10,7 @@
     "src/types.ts",
     "src/remote-events.ts",
     "src/agent.ts",
+    "src/assistant-stream.ts",
     "src/catalog.ts",
     "src/commands.ts",
     "src/control.ts",

+ 10 - 0
packages/client/connection/src/client/fixture.ts

@@ -125,6 +125,7 @@ type FixtureSessionAddress =
 
 interface FixtureFollowRequest {
   readonly address: FixtureSessionAddress
+  readonly assistantStream?: true
   readonly maxMessages?: number
 }
 
@@ -155,6 +156,10 @@ type FixtureFollowFrame =
     readonly records: readonly FixtureHistoryRecord[]
     readonly hasMore: boolean
     readonly projections: FixtureProjectionsBlock
+    readonly assistantStream?: {
+      readonly revision: 0
+      readonly attempts: readonly []
+    }
   }
   | FixtureHistoryEntry
 
@@ -3218,6 +3223,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         records: initial.records,
         hasMore: initial.hasMore,
         projections: { asOfSeq: cursor, values: projectionValuesOf(snapshot) },
+        // The fixture has no process-local frame producer, but an opted-in
+        // consumer still requires a complete opening baseline.
+        ...(request.assistantStream === true
+          ? { assistantStream: { revision: 0, attempts: [] } }
+          : {}),
       }
       for await (const frame of conn.drain(signal)) {
         if (frame.event.seq < nextSeq) continue

+ 19 - 1
packages/client/connection/tests/fixture.client.spec.ts

@@ -91,6 +91,10 @@ type FixtureFollowFrame =
       readonly asOfSeq: number
       readonly values: Readonly<Record<string, unknown>>
     }
+    readonly assistantStream?: {
+      readonly revision: 0
+      readonly attempts: readonly []
+    }
   }
   | FixtureHistoryEntry
 
@@ -451,7 +455,7 @@ function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote {
     modelCatalog: () => rpc.call('/api', 'session/modelCatalog', { args: {} }) as
       Promise<ConnectionRpcResult<ModelCatalog>>,
     follow: (sessionId, signal) => open<FixtureFollowFrame>('session/follow', {
-      request: { address: { kind: 'session', sessionId } },
+      request: { address: { kind: 'session', sessionId }, assistantStream: true },
     }, signal),
     control: signal => open<FixtureControlFrame>('session/control', {}, signal),
   }
@@ -615,6 +619,20 @@ describe('createFixtureApi', () => {
     expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
   })
 
+  it('returns an empty Assistant baseline when Session follow opts in', async () => {
+    const api = createFixtureApi()
+    const abort = new AbortController()
+    const iterator = api.sessionRemote.follow(sid('fx-alpha'), abort.signal)[Symbol.asyncIterator]()
+    try {
+      const opening = await iterator.next()
+      if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing')
+      expect(opening.value.assistantStream).toEqual({ revision: 0, attempts: [] })
+    } finally {
+      abort.abort()
+      await iterator.return?.()
+    }
+  })
+
   it('searches current message text with literal unicode61-style token phrases', async () => {
     const api = createFixtureApi()
     const signal = new AbortController().signal

+ 19 - 1
packages/core/agent-loop/src/agent.ts

@@ -34,6 +34,7 @@ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type { Context } from '@deepseek-ai/cordis'
 import { RuntimeContextProjection } from './runtime-context.ts'
+import { AssistantStreamAttempt } from './assistant-stream.ts'
 import { executeToolCalls } from './tool-calls.ts'
 
 type Phase =
@@ -84,6 +85,9 @@ export class ReactLoopAgent implements Agent {
   /** Surface generation of the preceding built request. */
   private requestSurfaceGeneration: number | undefined
   private readonly runtimeContext: RuntimeContextProjection
+  /** Process-local revision of assistant frames for this attached Session. */
+  private assistantStreamRevision = 0
+  private assistantAttemptCounter = 0
 
   constructor(
     private loopCtx: Context,
@@ -360,13 +364,24 @@ export class ReactLoopAgent implements Agent {
       startsRequestSeries = false
       const assembler = new BlockAssembler()
       const chunkSeqs: SessionSeq[] = []
+      const live = new AssistantStreamAttempt(
+        this.session.id,
+        ++this.assistantAttemptCounter,
+        () => ++this.assistantStreamRevision,
+        turn,
+        step,
+        (frame) => { this.dispatch.emit('agent/assistant-stream', { frame }) },
+      )
       try {
         const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
         signal.throwIfAborted()
+        live.start()
         for await (const chunk of stream) {
           signal.throwIfAborted()
-          chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
+          const legacyChunkSeq = this.session.append('assistant/chunk', { turn, step, chunk }).seq
+          chunkSeqs.push(legacyChunkSeq)
           assembler.push(chunk)
+          live.push(chunk, legacyChunkSeq)
         }
         signal.throwIfAborted()
       } catch (error: unknown) {
@@ -385,10 +400,12 @@ export class ReactLoopAgent implements Agent {
             }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
           }
         }
+        live.end('aborted')
         throw error
       }
       const finish = assembler.finish
       if (finish.kind === 'error' || finish.kind === 'aborted') {
+        live.end('aborted')
         const action = await this.dispatch.waterfall(
           'agent/request-error', {
             turn,
@@ -425,6 +442,7 @@ export class ReactLoopAgent implements Agent {
         },
         { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
       )
+      live.end('committed')
       if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
 
       const toolCalls = message.content.filter(block => block.type === 'tool-call')

+ 69 - 0
packages/core/agent-loop/src/assistant-stream.ts

@@ -0,0 +1,69 @@
+/** Process-local assistant attempt framing for live consumers. */
+
+import { LlmAttemptId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+
+/** Folds one model attempt into ordered transient frames. */
+export class AssistantStreamAttempt {
+  private readonly legacyChunkSeqs: SessionSeq[] = []
+  private index = 0
+  /** Process-local attempt identity. */
+  readonly attemptId: LlmAttemptId
+
+  /**
+   * @param sessionId - identity embedded only in the process-local attempt id.
+   * @param attempt - attached-Session-local attempt counter.
+   * @param nextRevision - allocates the next emitted frame revision.
+   * @param turn - durable turn owning the request.
+   * @param step - durable step owning the request.
+   * @param emit - agent-scoped notification publisher.
+   */
+  constructor(
+    sessionId: SessionId,
+    attempt: number,
+    private readonly nextRevision: () => number,
+    readonly turn: number,
+    readonly step: number,
+    private readonly emit: (frame: AssistantStreamFrame) => void,
+  ) {
+    this.attemptId = LlmAttemptId(`${sessionId}:${attempt}`)
+  }
+
+  /** Publish the opening marker before the first delivered chunk. */
+  start(): void {
+    this.emit({
+      type: 'start',
+      attemptId: this.attemptId,
+      revision: this.nextRevision(),
+      startedTime: Date.now(),
+      turn: this.turn,
+      step: this.step,
+    })
+  }
+
+  /** Publish one chunk only after its durable v1 record has appended. */
+  push(chunk: StreamChunk, legacyChunkSeq: SessionSeq): void {
+    this.legacyChunkSeqs.push(legacyChunkSeq)
+    this.emit({
+      type: 'chunk',
+      attemptId: this.attemptId,
+      revision: this.nextRevision(),
+      index: this.index++,
+      chunk,
+      legacyChunkSeq,
+    })
+  }
+
+  /** Publish terminal settlement after the matching durable assistant message commits. */
+  end(outcome: 'committed' | 'aborted'): void {
+    this.emit({
+      type: 'end',
+      attemptId: this.attemptId,
+      revision: this.nextRevision(),
+      index: this.index,
+      outcome,
+      legacyChunkSeqs: [...this.legacyChunkSeqs],
+    })
+  }
+}

+ 52 - 1
packages/core/agent-loop/tests/loop.spec.ts

@@ -4,7 +4,7 @@ import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId,
 import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
-import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
 
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -52,6 +52,51 @@ function userTexts(agent: Agent): string[] {
 }
 
 describe('agent loop', () => {
+  it('publishes one dense live assistant attempt while retaining durable v1 chunks', async () => {
+    const ctx = await harness(new MockAdapter([textResponse('live')]))
+    const agent = ctx.agentLoop.create(SessionId('live-assistant-attempt'), {
+      provider: 'mock',
+      model: 'mock',
+    })
+    const frames: AssistantStreamFrame[] = []
+    let committedAfterMessage = false
+    ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => {
+      if (subject !== agent) return
+      frames.push(frame)
+      if (frame.type === 'end' && frame.outcome === 'committed') {
+        committedAfterMessage = agent.session.snapshotEvents().at(-1)?.type === 'assistant/message'
+      }
+    })
+
+    send(agent, 'stream this')
+    await waitForIdle(ctx, agent)
+
+    expect(frames.at(0)?.type).toBe('start')
+    const start = frames.at(0)
+    if (start?.type === 'start') {
+      expect(typeof start.startedTime).toBe('number')
+      expect(Number.isSafeInteger(start.startedTime)).toBe(true)
+    }
+    expect(frames.at(-1)?.type).toBe('end')
+    expect(frames.map(frame => frame.revision)).toEqual(
+      frames.map((_frame, index) => index + 1),
+    )
+    const chunks = frames.filter(
+      (frame): frame is Extract<AssistantStreamFrame, { type: 'chunk' }> => frame.type === 'chunk',
+    )
+    expect(chunks.map(frame => frame.index)).toEqual(chunks.map((_frame, index) => index))
+    const durableChunks = agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk')
+    expect(chunks).toHaveLength(durableChunks.length)
+    const end = frames.at(-1)
+    expect(end).toMatchObject({
+      type: 'end', outcome: 'committed', index: chunks.length,
+    })
+    if (end?.type === 'end') {
+      expect(committedAfterMessage).toBe(true)
+      expect(end.legacyChunkSeqs).toEqual(durableChunks.map(event => event.seq))
+    }
+  })
+
   it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
     'rejects invalid AgentOptions.maxTokens %s before publication',
     async (maxTokens) => {
@@ -979,7 +1024,9 @@ describe('agent loop', () => {
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
     const reasons: TurnEndReason[] = []
+    const frames: AssistantStreamFrame[] = []
     ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
+    ctx.on('agent/assistant-stream', ({ frame }) => { frames.push(frame) })
 
     send(agent, 'go')
     // wait until the stream is hanging, then cancel
@@ -989,6 +1036,10 @@ describe('agent loop', () => {
     await waitForIdle(ctx, agent)
 
     expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
+    const chunks = frames.filter(frame => frame.type === 'chunk')
+    expect(frames.at(-1)).toMatchObject({
+      type: 'end', outcome: 'aborted', index: chunks.length,
+    })
   })
 
   it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {

+ 2 - 2
packages/core/agent/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/agent/README.md
-README.md: 906f377fe0a5272ce3a4705536ccaa6b84c4afcf
-README.zh.md: 7ae6dd766c78ca0088806c10e567ea6c389f4cf8
+README.md: 11218f796675d4361e507ab4305c926c2756edb2
+README.zh.md: e15f2fd8295a5891c69814a3e02beff1e2c2b5a1

+ 1 - 1
packages/core/agent/README.md

@@ -64,7 +64,7 @@ await handle.agent.whenIdle()
 
 ### Intercept or observe work in flight
 
-The `agent/*` events let plugins act on live work without depending on the loop package. `agent/pre-step` can reject a proposed step or replace the messages entering it; `agent/request-error` lets a listener retry a failed model request; `agent/turn-stopping` runs before an otherwise completed turn closes and can steer to keep it open. `agent/status`, `agent/created`, and `agent/disposed` drive UI and coordination state, and the per-message `agent/inbox/*` notifications keep inbox projections in sync. Exact signatures, dispatch modes, and payload contracts live in the generated region of the [core subsystem page](../../../docs/subsystems/core.md#cordis-surface).
+The `agent/*` events let plugins act on live work without depending on the loop package. `agent/pre-step` can reject a proposed step or replace the messages entering it; `agent/request-error` lets a listener retry a failed model request; `agent/turn-stopping` runs before an otherwise completed turn closes and can steer to keep it open. `agent/assistant-stream` carries one process-local assistant attempt's ordered frames after their durable v1 records append; `start` records the attempt's safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, and `end.index` is the next chunk position. These frames are presentation data, not a replay source. `agent/status`, `agent/created`, and `agent/disposed` drive UI and coordination state, and the per-message `agent/inbox/*` notifications keep inbox projections in sync. Exact signatures, dispatch modes, and payload contracts live in the generated region of the [core subsystem page](../../../docs/subsystems/core.md#cordis-surface).
 
 -----
 

+ 1 - 1
packages/core/agent/README.zh.md

@@ -64,7 +64,7 @@ await handle.agent.whenIdle()
 
 ### 拦截或观察进行中的工作
 
-`agent/*` 事件让插件无需依赖循环包即可作用于实时工作。`agent/pre-step` 可以拒绝拟进入的步骤或替换进入它的消息;`agent/request-error` 让监听器重试失败的模型请求;`agent/turn-stopping` 在本可完成的轮次关闭前运行,并可通过 steer 使其保持打开。`agent/status`、`agent/created` 与 `agent/disposed` 驱动 UI 与协调状态,逐消息的 `agent/inbox/*` 通知则让收件箱投影保持同步。确切签名、分发 mode 与 payload 约定见 [core 子系统页](../../../docs/subsystems/core.zh.md#cordis-surface) 的生成区块。
+`agent/*` 事件让插件无需依赖循环包即可作用于实时工作。`agent/pre-step` 可以拒绝拟进入的步骤或替换进入它的消息;`agent/request-error` 让监听器重试失败的模型请求;`agent/turn-stopping` 在本可完成的轮次关闭前运行,并可通过 steer 使其保持打开。`agent/assistant-stream` 携带一个进程本地 assistant 尝试在其持久 v1 记录追加后的有序帧;`start` 把该尝试的壁钟时间记录为安全整数 `startedTime`,chunk index 从零开始连续递增,`end.index` 则是下一个 chunk 位置。这些帧是呈现数据,不是重放来源。`agent/status`、`agent/created` 与 `agent/disposed` 驱动 UI 与协调状态,逐消息的 `agent/inbox/*` 通知则让收件箱投影保持同步。确切签名、分发 mode 与 payload 约定见 [core 子系统页](../../../docs/subsystems/core.zh.md#cordis-surface) 的生成区块。
 
 -----
 

+ 48 - 2
packages/core/agent/src/runtime-types.ts

@@ -7,8 +7,10 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
-import type { LlmCallConfig, LlmFailure, ReasoningEffortId, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
-import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session'
+import type {
+  LlmAttemptId, LlmCallConfig, LlmFailure, ReasoningEffortId, ResolvedRetryPolicy, StreamChunk,
+} from '@deepseek-ai/dsh-llm'
+import type { AgentCancelCause, Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session'
 export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
 import type { Inbox } from './inbox.ts'
 import type { Agent } from './types.ts'
@@ -68,6 +70,40 @@ export type RequestErrorAction = { kind: 'retry' } | undefined
 /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
 export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
 
+/** One process-local live assistant streaming publication. */
+export type AssistantStreamFrame =
+  | {
+    readonly type: 'start'
+    readonly attemptId: LlmAttemptId
+    /** Monotone while this Session remains attached to this process. */
+    readonly revision: number
+    /** Safe-integer wall-clock time captured when this attempt started. */
+    readonly startedTime: number
+    readonly turn: number
+    readonly step: number
+  }
+  | {
+    readonly type: 'chunk'
+    readonly attemptId: LlmAttemptId
+    readonly revision: number
+    /** Dense zero-based position within the attempt. */
+    readonly index: number
+    readonly chunk: StreamChunk
+    /** Matching durable v1 `assistant/chunk` record for duplicate suppression. */
+    readonly legacyChunkSeq: SessionSeq
+  }
+  | {
+    readonly type: 'end'
+    readonly attemptId: LlmAttemptId
+    readonly revision: number
+    /** Number of chunk frames emitted by this attempt. */
+    readonly index: number
+    /** The durable assistant message committed before this notification. */
+    readonly outcome: 'committed' | 'aborted'
+    /** Every durable v1 chunk represented by this attempt. */
+    readonly legacyChunkSeqs: readonly SessionSeq[]
+  }
+
 declare module './types.ts' {
   interface Agent {
     /** The provider route and model this agent's requests use. */
@@ -265,6 +301,16 @@ declare module '@deepseek-ai/cordis' {
      * @mode waterfall
      */
     'agent/request-error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
+    /**
+     * Process-local assistant-stream publication. The loop appends each v1
+     * `assistant/chunk` before the matching chunk frame and appends the final
+     * `assistant/message` before a committed end frame.
+     * @param payload.agent - the agent whose attempt produced the frame.
+     * @param payload.frame - one ordered start, chunk, or end publication.
+     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
+     * @mode emit
+     */
+    'agent/assistant-stream'(this: Scoped<Agent>, payload: { agent: Agent; frame: AssistantStreamFrame }): void
     /**
      * The turn is about to close: the model owes no response (no live tool
      * calls, no fresh steering). Awaited before the boundary commits — a

+ 1 - 0
packages/core/scope/src/scoped-events.generated.ts

@@ -8,6 +8,7 @@
 type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
 
 const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
+  'agent/assistant-stream': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/created': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/disposed': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/error': args => (args[0] as Record<string, unknown>)['agent'],

+ 7 - 0
packages/core/scope/tests/invariant.spec.ts

@@ -54,6 +54,13 @@ describe('scoped-dispatch invariants', () => {
       'agent/session-start': [{ agent, source: 'startup' }],
       'agent/pre-step': [{ agent, messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })],
       'agent/request': [{ agent, turn: 1, step: 1, signal }, () => Promise.resolve(config)],
+      'agent/assistant-stream': [{
+        agent,
+        frame: {
+          type: 'start', attemptId: 'attempt-1' as never, revision: 1,
+          startedTime: 1, turn: 1, step: 1,
+        },
+      }],
       'agent/request-error': [
         {
           agent,

+ 30 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2890,6 +2890,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'One session committed a different agent preset to its durable log. Consumers invalidate only state derived from that session\'s composition.',
     parameters: [{ name: 'sessionId', description: 'the session whose composition changed.' }, { name: 'agentPreset', description: 'the preset recorded by the committed selection.' }],
   },
+  {
+    name: 'agent/assistant-stream',
+    mode: 'emit',
+    signature: '\'agent/assistant-stream\'(this: Scoped<Agent>, payload: { agent: Agent; frame: AssistantStreamFrame }): void',
+    summary: 'Process-local assistant-stream publication.',
+    description: 'Process-local assistant-stream publication. The loop appends each v1 `assistant/chunk` before the matching chunk frame and appends the final `assistant/message` before a committed end frame.',
+    parameters: [{ name: 'payload', description: '.frame - one ordered start, chunk, or end publication. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }],
+  },
   {
     name: 'agent/created',
     mode: 'emit',
@@ -3538,6 +3546,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'AssistantProvenance',
     declaration: 'export interface AssistantProvenance {\n    provider: string;\n    model: string;\n    replayState?: unknown;\n}',
   },
+  {
+    name: 'AssistantStreamFrame',
+    declaration: 'export type AssistantStreamFrame = {\n    readonly type: \'start\';\n    readonly attemptId: LlmAttemptId;\n    readonly revision: number;\n    readonly startedTime: number;\n    readonly turn: number;\n    readonly step: number;\n} | {\n    readonly type: \'chunk\';\n    readonly attemptId: LlmAttemptId;\n    readonly revision: number;\n    readonly index: number;\n    readonly chunk: StreamChunk;\n    readonly legacyChunkSeq: SessionSeq;\n} | {\n    readonly type: \'end\';\n    readonly attemptId: LlmAttemptId;\n    readonly revision: number;\n    readonly index: number;\n    readonly outcome: \'committed\' | \'aborted\';\n    readonly legacyChunkSeqs: readonly SessionSeq[];\n};',
+  },
   {
     name: 'AttachmentId',
     declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;',
@@ -4278,6 +4290,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'LlmAdapter',
     declaration: 'export abstract class LlmAdapter {\n    providerInfo(provider: string): LlmProviderInfo;\n    providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;\n    imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined;\n    listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n    resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;\n    async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall>;\n    abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
   },
+  {
+    name: 'LlmAttemptId',
+    declaration: 'export type LlmAttemptId = Branded<\'LlmAttemptId\'>;',
+  },
   {
     name: 'LlmCallConfig',
     declaration: 'export interface LlmCallConfig {\n    provider: string;\n    model: string;\n    reasoningEffort?: ReasoningEffortId;\n    temperature?: number;\n    maxTokens?: number;\n    stop?: string[];\n}',
@@ -4814,6 +4830,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionAddress',
     declaration: 'export type SessionAddress = {\n    readonly kind: \'session\';\n    readonly sessionId: SessionId;\n} | {\n    readonly kind: \'subagent\';\n    readonly parentSessionId: SessionId;\n    readonly childSessionId: SessionId;\n    readonly mode: \'one-shot\' | \'continuable\';\n};',
   },
+  {
+    name: 'SessionAssistantStreamAttempt',
+    declaration: 'export interface SessionAssistantStreamAttempt {\n    readonly attemptId: LlmAttemptId;\n    readonly startedTime: number;\n    readonly turn: number;\n    readonly step: number;\n    readonly chunks: readonly JsonValue[];\n    readonly legacyChunkSeqs: readonly number[];\n}',
+  },
+  {
+    name: 'SessionAssistantStreamBaseline',
+    declaration: 'export interface SessionAssistantStreamBaseline {\n    readonly revision: number;\n    readonly attempts: readonly SessionAssistantStreamAttempt[];\n}',
+  },
+  {
+    name: 'SessionAssistantStreamFrame',
+    declaration: 'export type SessionAssistantStreamFrame = {\n    readonly type: \'start\';\n    readonly attemptId: LlmAttemptId;\n    readonly revision: number;\n    readonly startedTime: number;\n    readonly turn: number;\n    readonly step: number;\n} | {\n    readonly type: \'chunk\';\n    readonly attemptId: LlmAttemptId;\n    readonly revision: number;\n    readonly index: number;\n    readonly chunk: JsonValue;\n    readonly legacyChunkSeq: number;\n} | {\n    readonly type: \'end\';\n    readonly attemptId: LlmAttemptId;\n    readonly revision: number;\n    readonly index: number;\n    readonly outcome: \'committed\' | \'aborted\';\n    readonly legacyChunkSeqs: readonly number[];\n};',
+  },
   {
     name: 'SessionAttachmentRequest',
     declaration: 'export interface SessionAttachmentRequest {\n    readonly sessionId: SessionId;\n    readonly attachmentId: AttachmentIdType;\n}',
@@ -4928,11 +4956,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionFollowFrame',
-    declaration: 'export type SessionFollowFrame = {\n    readonly type: \'snapshot\';\n    readonly header: SessionWireHeader;\n    readonly cursor: number;\n    readonly records: readonly SessionHistoryRecord[];\n    readonly hasMore: boolean;\n    readonly projections: SessionProjectionBaseline;\n} | SessionEventEntry;',
+    declaration: 'export type SessionFollowFrame = {\n    readonly type: \'snapshot\';\n    readonly header: SessionWireHeader;\n    readonly cursor: number;\n    readonly records: readonly SessionHistoryRecord[];\n    readonly hasMore: boolean;\n    readonly projections: SessionProjectionBaseline;\n    readonly assistantStream?: SessionAssistantStreamBaseline;\n} | SessionEventEntry | {\n    readonly type: \'assistant-stream\';\n    readonly frame: SessionAssistantStreamFrame;\n};',
   },
   {
     name: 'SessionFollowRequest',
-    declaration: 'export interface SessionFollowRequest {\n    readonly address: SessionAddress;\n    readonly maxMessages?: number;\n}',
+    declaration: 'export interface SessionFollowRequest {\n    readonly address: SessionAddress;\n    readonly maxMessages?: number;\n    readonly assistantStream?: true;\n}',
   },
   {
     name: 'SessionForkRequest',

+ 12 - 0
packages/llm/llm/src/brand.ts

@@ -51,6 +51,18 @@ export function ProviderRequestId(id: string): ProviderRequestId {
   return brandString<ProviderRequestId>(id)
 }
 
+/** Process-local identity of one loop-owned model streaming attempt. */
+export type LlmAttemptId = Branded<'LlmAttemptId'>
+
+/**
+ * Brand one loop-owned streaming attempt identifier.
+ * @param id - the opaque process-local identifier.
+ * @returns the same string with the attempt-id brand.
+ */
+export function LlmAttemptId(id: string): LlmAttemptId {
+  return brandString<LlmAttemptId>(id)
+}
+
 /** Adapter-owned identifier for one model's selectable reasoning effort. */
 export type ReasoningEffortId = Branded<'ReasoningEffortId'>
 

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -246,6 +246,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SubagentModelSelectionSettings: 'subagent.md',
   AgentOptions: 'core.md',
   AgentStatus: 'core.md',
+  AssistantStreamFrame: 'core.md',
   ContentBlock: 'llm-streaming.md',
   CreateAgentOptions: 'core.md',
   GenerateOptions: 'llm-streaming.md',