Browse Source

Merge pull request #2746 from deepseek-harness/xtr/durable-inbox-web-recovery

fix(web): restore pending Inbox after restart
_Kerman 6 days ago
parent
commit
31209ea9d7
100 changed files with 1135 additions and 1001 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml
  8. 4 4
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
  9. 4 4
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  11. 7 6
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  12. 7 6
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml
  14. 51 0
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
  15. 51 0
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
  17. 1 1
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
  18. 1 1
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md
  19. 2 2
      .agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml
  20. 2 14
      .agents/notes/proposed/feature/2026-08-04-task-surface.md
  21. 2 14
      .agents/notes/proposed/feature/2026-08-04-task-surface.zh.md
  22. 75 0
      apps/web/tests/cold-blank-session.e2e.ts
  23. 9 0
      apps/web/tests/expected/cold-blank-session/queue.expected.md
  24. 3 1
      apps/web/tests/workspace-recency.e2e.ts
  25. 1 0
      apps/web/tsconfig.json
  26. 2 2
      docs/architecture.i18n.yaml
  27. 1 1
      docs/architecture.md
  28. 1 1
      docs/architecture.zh.md
  29. 2 2
      docs/config-catalog.i18n.yaml
  30. 1 1
      docs/config-catalog.md
  31. 1 1
      docs/config-catalog.zh.md
  32. 2 2
      docs/event-producer-consumer.i18n.yaml
  33. 6 6
      docs/event-producer-consumer.md
  34. 6 6
      docs/event-producer-consumer.zh.md
  35. 2 2
      docs/subsystems/core.i18n.yaml
  36. 1 1
      docs/subsystems/core.md
  37. 1 1
      docs/subsystems/core.zh.md
  38. 2 2
      docs/subsystems/session-projection.i18n.yaml
  39. 1 1
      docs/subsystems/session-projection.md
  40. 1 1
      docs/subsystems/session-projection.zh.md
  41. 2 2
      docs/subsystems/session.i18n.yaml
  42. 2 2
      docs/subsystems/session.md
  43. 2 2
      docs/subsystems/session.zh.md
  44. 2 2
      packages/api/session-controller/README.i18n.yaml
  45. 0 0
      packages/api/session-controller/README.md
  46. 0 0
      packages/api/session-controller/README.zh.md
  47. 5 1
      packages/api/session-controller/src/agent.ts
  48. 0 15
      packages/api/session-controller/src/client/contract/snapshot.ts
  49. 11 5
      packages/api/session-controller/src/client/index.ts
  50. 17 31
      packages/api/session-controller/src/client/sessions/manager.ts
  51. 6 13
      packages/api/session-controller/src/client/sessions/projection-store.ts
  52. 0 71
      packages/api/session-controller/src/client/sessions/queue-mirror.ts
  53. 27 37
      packages/api/session-controller/src/client/sessions/session.ts
  54. 9 4
      packages/api/session-controller/src/commands.ts
  55. 3 45
      packages/api/session-controller/src/control.ts
  56. 2 2
      packages/api/session-controller/src/index.ts
  57. 0 15
      packages/api/session-controller/src/types.ts
  58. 32 1
      packages/api/session-controller/tests/client-apply.client.spec.ts
  59. 29 9
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  60. 1 1
      packages/api/session-controller/tests/commands-upload-file.host.spec.ts
  61. 31 41
      packages/api/session-controller/tests/control-queue.host.spec.ts
  62. 172 0
      packages/api/session-controller/tests/inbox-projection.client.spec.ts
  63. 87 28
      packages/api/session-controller/tests/manager.client.spec.ts
  64. 27 12
      packages/api/session-controller/tests/projection-store.client.spec.ts
  65. 0 289
      packages/api/session-controller/tests/queue-store.client.spec.ts
  66. 5 48
      packages/api/session-controller/tests/remote/session.client.ts
  67. 109 1
      packages/api/session-controller/tests/session-cold.host.spec.ts
  68. 32 9
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  69. 4 1
      packages/api/session-controller/tests/session-projections.host.spec.ts
  70. 1 1
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  71. 3 3
      packages/api/session-controller/tests/transport.client.spec.ts
  72. 6 5
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  73. 37 37
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  74. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  75. 1 1
      packages/client/ui-conversation/README.md
  76. 1 1
      packages/client/ui-conversation/README.zh.md
  77. 2 1
      packages/client/ui-conversation/package.json
  78. 3 6
      packages/client/ui-conversation/src/client/contract/input.ts
  79. 0 11
      packages/client/ui-conversation/src/client/contract/queue.ts
  80. 10 6
      packages/client/ui-conversation/src/client/input/facade.ts
  81. 5 3
      packages/client/ui-conversation/src/client/input/hub.ts
  82. 0 25
      packages/client/ui-conversation/src/client/input/queue-store.ts
  83. 38 16
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  84. 4 3
      packages/client/ui-conversation/src/client/service.ts
  85. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  86. 0 1
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  87. 11 10
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  88. 19 0
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  89. 82 49
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  90. 15 23
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  91. 3 0
      packages/client/ui-conversation/tsconfig.json
  92. 0 1
      packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx
  93. 0 1
      packages/client/ui-session/tests/ui-session.client.spec.ts
  94. 0 1
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  95. 0 1
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  96. 0 1
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  97. 2 2
      packages/core/agent-loop/README.i18n.yaml
  98. 0 0
      packages/core/agent-loop/README.md
  99. 0 0
      packages/core/agent-loop/README.zh.md
  100. 2 5
      packages/core/agent-loop/src/inbox.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-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 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
-2026-07-19-gui-web-client-architecture.md: 55421d1ad6df192d08c431af3633675036a4a857
-2026-07-19-gui-web-client-architecture.zh.md: 6fb3f9a512389710f6708b7f36f42e90eef11b28
+2026-07-19-gui-web-client-architecture.md: 4d62d7ddbeddd2d5c02e42194b035ee08b5cf041
+2026-07-19-gui-web-client-architecture.zh.md: aeb6c89e677d7b65daef03d5d31372f484ae3117

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md

@@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 ```
 
 - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
-- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates.
+- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, running, removal, open state, paging, and prompt errors remain Session facts; pending Inbox values live in the generic Session projection store. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React reads Session lifecycle through `useSession(selector)` and domain projections through `useProjection(key, selector)`, so each hook isolates unrelated updates.
 - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
 - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
 - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md

@@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 ```
 
 - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
-- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。
+- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、running、removed、open state、paging 和 prompt error 仍是 Session 信息;待处理 Inbox 值则位于通用 Session projection store。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 通过 `useSession(selector)` 读取 Session lifecycle,通过 `useProjection(key, selector)` 读取领域投影,使每个 hook 都隔离无关更新。
 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染。

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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 .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 4cdca4c1cb5b512c1696a397bbb6e4d070a6a487
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 0258609fd8df90dc2133fcb8bf6e7e50efaab941
+2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md

@@ -99,7 +99,7 @@ Slot scope is the closed set `root | session-maybe | session`:
 - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
 - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`).
 
-### The read-only queue mirror
+### Input delivery
 
 - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md

@@ -99,7 +99,7 @@ slot scope 是闭集 `root | session-maybe | session`:
 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。
 
-### 队列只读镜像
+### 输入投递
 
 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.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 .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
-2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888
-2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18
+2026-07-31-claimed-pre-step-inbox-lifecycle.md: 3ebb73279e49c2aaffa05bea647fa3f93ba6a36f
+2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 809470e5b52b29cdcbe7b4d81bb584b476f5cfce

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md

@@ -18,13 +18,13 @@ Before every proposed step, the loop's package-internal `ReactLoopInbox` atomica
 
 The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields.
 
-`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes.
+`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context`. `AgentLoop` owns the standard projection registration for its service lifetime, keeping cold Inbox reads available without any live Agent; `ReactLoopInbox` only reads and mutates that shared state.
 
-The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value.
+The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentLoop` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the generic projection change feed and read the complete Inbox value directly.
 
 Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued.
 
-The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.
+The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `AgentLoop` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.
 
 ## Alternatives considered
 
@@ -36,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026
 
 ## Verification
 
-Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
+Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and Inbox availability across Agent unloads and projection removal when AgentLoop unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
 
 ## Consequences
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md

@@ -18,13 +18,13 @@ Status: implemented
 
 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
 
-`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数
+`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`。`AgentLoop` 在服务生命周期内持有标准投影注册,让没有 live Agent 时的冷 Inbox 读取仍然可用;`ReactLoopInbox` 只读取和变更该共享状态
 
-两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement
+两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentLoop` 在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用通用投影变更流,直接读取完整的 Inbox 值
 
 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。
 
-已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。
+已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `AgentLoop` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。
 
 ## 曾考虑的替代方案
 
@@ -36,7 +36,7 @@ Status: implemented
 
 ## 验证
 
-agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
+agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及Agent 卸载后 Inbox 仍可读取,以及 AgentLoop 卸载时移除投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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 .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
-2026-08-18-session-history-and-event-transport.md: 8781ea265798ff200a6e0a58542b7d03693d8bd3
-2026-08-18-session-history-and-event-transport.zh.md: 1603f5aed8adb7a42fecab92511176d2147be5c3
+2026-08-18-session-history-and-event-transport.md: db5c403562101bca76c0d39a037a199b2ca164f3
+2026-08-18-session-history-and-event-transport.zh.md: 41ff3ea2dd538edb457e0db3108a900bab220e47

+ 7 - 6
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md

@@ -8,7 +8,7 @@ English | [中文](2026-08-18-session-history-and-event-transport.zh.md)
 
 The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay.
 
-These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.
+These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; jobs, projection values, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.
 
 Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects.
 
@@ -158,7 +158,8 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca
 | `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background |
 | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent |
 | `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent |
-| `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state |
+| `session.updateQueue` | live Agent or ordinary persisted Session | Resumes an ordinary cold Session before mutating its Inbox |
+| `session.cancel` | only the current live Agent | Does not resume vanished state |
 | `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it |
 | `create` and fork target | new Session/Agent | The user command supplies creation authority |
 
@@ -204,9 +205,9 @@ A terminal failure from the initial page, repair page, or follow enters the curr
 
 `session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript.
 
-Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents.
+Each generation emits a complete baseline first, followed by jobs and projection deltas. The baseline reads process-local registries and folded projection values without resuming cold Agents.
 
-Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline.
+Jobs use complete replacement values and apply last-wins. Projection updates carry monotonically increasing revisions, while a new baseline replaces the complete projection map. Session and owner disposal clear stale mirrors.
 
 The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests.
 
@@ -313,7 +314,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re
 
 **Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently.
 
-**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`.
+**Move jobs, projections, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`.
 
 **Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics.
 
@@ -345,7 +346,7 @@ Connection tests pin missing, duplicate, and withdrawn generation sources, readi
 
 Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection.
 
-Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark.
+Session control tests pin baseline-first delivery, no cold-Session resume, jobs replacement, and the projection watermark.
 
 Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry.
 

+ 7 - 6
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。
 
-这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。
+这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;jobs、projection 值和 Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。
 
 观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。
 
@@ -158,7 +158,8 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类
 | `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 |
 | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent |
 | `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent |
-| `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent |
+| `session.updateQueue` | live Agent 或普通持久 Session | 修改 Inbox 前恢复普通冷 Session |
+| `session.cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent |
 | `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 |
 | `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 |
 
@@ -204,9 +205,9 @@ initial page、repair page 或 follow 的 terminal failure 进入当前 Session
 
 `session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。
 
-每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。
+每个 generation 先发完整 baseline,再发 jobs 与 projection 增量帧。baseline 读取进程内 registry 和已折叠的 projection 值,不恢复冷 Agent。
 
-queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。
+jobs 使用完整 replacement 值并按 last-wins 应用。Projection update 携带单调递增 revision,新 baseline 则替换完整 projection map。Session 与 owner disposal 会清理陈旧镜像。
 
 原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。
 
@@ -313,7 +314,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace
 
 **把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。
 
-**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。
+**把 jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。
 
 **让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。
 
@@ -345,7 +346,7 @@ Connection 测试固定 generation source 缺失、重复注册、撤回、ready
 
 Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。
 
-Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。
+Session control 测试固定 baseline-first、冷 Session 不恢复、jobs replacement 与 projection watermark。
 
 Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.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/bug-fix/2026-08-17-durable-web-queue-recovery.md
+2026-08-17-durable-web-queue-recovery.md: c235cc47d5a68623249765753c2757948721ff6e
+2026-08-17-durable-web-queue-recovery.zh.md: 2b224b5e1d63905e738dfda0772a3ea578c9aef3

+ 51 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md

@@ -0,0 +1,51 @@
+# Agent Note: Recover the Web queue from durable Inbox state
+
+Status: implemented
+
+English | [中文](2026-08-17-durable-web-queue-recovery.zh.md)
+
+## Problem
+
+Inbox acceptance records normalized `agent/inbox/spliced` events, but the Web queue used a separate mux baseline built by enumerating live Agents. After a Host process restart, a persisted ordinary Session remained cold until an operation needed its Agent, so the live-only baseline omitted accepted pending messages that were still present in the durable log.
+
+A reconnect-only repair would retain two recovery implementations: one for a live Inbox and one for cold Web reads. The correct owner is the Inbox domain, and the session-projection framework already provides live drive, cold folding, reconnect baselines, and cache restoration.
+
+## Decision
+
+When composed with the Session projection registry, `AgentLoop` registers the standard `inbox` projection at service activation so cold Sessions can be read without a live Agent. The [claimed Inbox lifecycle](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md) owns splice normalization, message uniqueness, live notifications, and durable reconstruction. The projection shares one schema and `InboxState` definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator.
+
+The registry folds committed splices before `Session.append()` returns; each Agent's `ReactLoopInbox` command facade reads that same live state rather than keeping another fold.
+
+The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block in each `session.follow` opening snapshot, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration.
+
+A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all incoming baselines obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer list or history values.
+
+The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection.
+
+`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before mutating its Inbox. A restored pending row therefore remains editable, removable, or steerable after restart, while subagent ownership keeps the same fence as other Agent operations.
+
+No new session event or on-disk format is introduced. The existing splice stream remains the durable source of truth.
+
+## Verification
+
+Host projection coverage reads a detached persisted Session with pending input, returns `values.inbox` in the opening `session.follow` snapshot, and proves that no live Agent is required. Cold-operation coverage proves `session.updateQueue` resumes the Session and appends the durable removal splice.
+
+Client coverage pins generic Inbox projection delivery, reconnect invalidation for omitted cold Sessions, both baseline arrival orders, obsolete list request outcomes, higher-seq retention before Session materialization, and the absence of queue state from `SessionSnapshot`. UI coverage pins direct `next-turn` QueueDock rendering and user-origin `next-step` ChatView rendering. The keyless Web fixture opens a cold persisted Session and observes its pending row after restart.
+
+## Alternatives considered
+
+**Add cold Sessions to the old queue reconnect loop.** Rejected because it would duplicate the projection registry's cold fold and preserve separate implementations for live pushes, history, cache, and reconnect.
+
+**Register a Web-specific `queue` projection in Session Controller.** Rejected because pending input belongs to Inbox. Placement rows and a handoff list would introduce a second domain model solely for one client.
+
+**Store a complete Inbox snapshot on every splice event.** Rejected because the durable event is a normalized mutation, not a repeated aggregate. The projection framework owns aggregate reconstruction and checkpointing.
+
+**Reconstruct Inbox in the client from raw session events.** Rejected because pagination may omit the insertion that established current state and every client would duplicate splice semantics.
+
+**Resume every cold Agent while opening the mux stream.** Rejected because displaying durable state must not publish runtime resources, mount presets, or start lifecycle work.
+
+## Consequences
+
+Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks.
+
+Clients receive the raw two-list Inbox value and decide which messages their surface presents. The projection state version invalidates cached rows whenever its serialized state or fold semantics change.

+ 51 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: 从持久 Inbox 状态恢复 Web Queue
+
+Status: implemented
+
+[English](2026-08-17-durable-web-queue-recovery.md) | 中文
+
+## 问题
+
+Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 Web Queue 使用另一份通过枚举 live Agent 构建的 mux 基线。Host 进程重启后,持久化的普通 Session 会保持冷状态,直到某项操作需要其 Agent,因此 live-only 基线会遗漏仍存在于持久日志中的已接受待处理消息。
+
+只修复重连逻辑仍会保留两套恢复实现:一套用于 live Inbox,另一套用于 Web 冷读取。正确的所有者是 Inbox 领域,而会话投影框架已经提供 live 驱动、冷折叠、重连基线和缓存恢复。
+
+## 决策
+
+组合了 Session projection registry 时,`AgentLoop` 在服务激活时注册标准 `inbox` 投影,使冷 Session 无需 live Agent 即可读取。[Inbox 认领生命周期](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md) 定义 splice 规范化、消息唯一性、live 通知和持久重建。投影共用一份 schema 与 `InboxState` 定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。
+
+注册表在 `Session.append()` 返回前折叠已提交的 splice;每个 Agent 的 `ReactLoopInbox` 命令 facade 都读取同一份 live 状态,而不另行维护折叠状态。
+
+通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在每次 `session.follow` 的起始快照中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。
+
+对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有收到的 baseline 都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 list 或 history 值。
+
+客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。
+
+`session.updateQueue` 在修改 Inbox 前通过共享 Agent 解析器解析普通冷 Session。因此,恢复出的待处理行在重启后仍可编辑、移除或 steering,而 subagent ownership 保持与其他 Agent 操作相同的 fence。
+
+系统没有引入新的会话事件或磁盘格式。既有 splice 流仍是持久真源。
+
+## 验证
+
+Host 投影覆盖会读取包含待处理输入的已分离持久 Session,在 `session.follow` 的起始快照中返回 `values.inbox`,并证明不需要 live Agent。冷操作覆盖证明 `session.updateQueue` 会恢复 Session 并追加持久删除 splice。
+
+客户端覆盖固定通用 Inbox 投影投递、重连时清理遗漏冷 Session 的旧值、基线的两种到达顺序、过期 list 请求的结果、Session 实例化前保留 seq 更高的值,以及 `SessionSnapshot` 不含 queue 状态。UI 覆盖固定 QueueDock 直接渲染 `next-turn`,以及 ChatView 渲染用户来源的 `next-step`。无密钥 Web fixture 会打开一份冷持久 Session,并在重启后观察其待处理行。
+
+## 考虑过的替代方案
+
+**把冷 Session 加入旧 queue 重连循环。** 不予采纳,因为这会重复投影注册表的冷折叠,并让实时推送、历史、缓存和重连继续使用不同实现。
+
+**在 Session Controller 注册 Web 专属 `queue` 投影。** 不予采纳,因为待处理输入属于 Inbox。placement 行与 handoff 列表会只为一个客户端引入第二套领域模型。
+
+**在每条 splice 事件中保存完整 Inbox 快照。** 不予采纳,因为持久事件是规范化变更,不是重复聚合。聚合重建与 checkpoint 属于投影框架。
+
+**在客户端根据原始会话事件重建 Inbox。** 不予采纳,因为分页可能省略建立当前状态的插入事件,每个客户端也会重复实现 splice 语义。
+
+**打开 mux 流时恢复每个冷 Agent。** 不予采纳,因为展示持久状态不应发布运行时资源、挂载 preset 或启动生命周期工作。
+
+## 后果
+
+待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。
+
+客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.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 .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
-2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27
-2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a
+2026-08-08-web-background-job-display.md: 4b8396c1c0504ddf8494a03c22b9af094c4eed17
+2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md

@@ -81,7 +81,7 @@ Four rules the carrier keeps:
 
 `SessionListState` carries `jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation.
 
-It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel.
+It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, no composer behavior depends on tasks, and a later sidebar indicator gets the data without opening a second channel.
 
 Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md

@@ -81,7 +81,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
 
 `SessionListState` 带有 `jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。
 
-它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。
+它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。
 
 两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。
 

+ 2 - 2
.agents/notes/proposed/feature/2026-08-04-task-surface.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 .agents/notes/proposed/feature/2026-08-04-task-surface.md
-2026-08-04-task-surface.md: d5d04a7ac8aee9936e1913430c2efa6e6c005206
-2026-08-04-task-surface.zh.md: ecba9ef7f9b45bb9f12673c30d9a9b47fb93a30e
+2026-08-04-task-surface.md: 749ce9e40eea7aeb7a813dc6b2d4c139cf00ac44
+2026-08-04-task-surface.zh.md: 4a63e671ffe05c80dd8bc8ed3a49ab6ff5c44b7e

+ 2 - 14
.agents/notes/proposed/feature/2026-08-04-task-surface.md

@@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource {
 }
 ```
 
-The `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation:
-
-```ts ignore-check
-interface QueuedMessage {
-  id: InboxItemId
-  messageId: MessageId
-  placement: 'queued' | 'steering'
-  source: MessageSource
-  content: readonly ContentBlock[]
-  preview: string
-  text: string | null
-}
-```
+The standard `inbox` projection already carries each complete `UserMessage`, including its `MessageId`, source, and content, in the raw `next-turn` or `next-step` list. The client retains that value in its generic projection store, so this proposal needs no queue transport extension or second pending-message type.
 
 The browser-safe domain package owns `TaskSurfaceId`, the submission and dismissal IDs, `TaskSurfaceCorrelation`, and the pending-submission shape. ApiProxy owns the transport augmentation that combines the correlation with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction.
 
@@ -198,7 +186,7 @@ The product shell owns collapse and dismiss. Collapse is local view state and se
 
 Submission is transactional at the client boundary. Acceptance returns the exact `messageId` in phase `queued`; the Dock disables every mutation through both `queued` and `claiming` and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId` and return the first result; another submission ID receives `submission-pending` while the first is live. The Host admits one user message for one accepted Surface.
 
-The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent dequeues that occurrence for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before ApiProxy publishes the ordinary queue snapshot without the claimed row. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard.
+The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent claims that message for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before the durable deletion splice removes it from the generic `inbox` projection. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard.
 
 The matching `user/message` closes the durable projection and clears the claim. Rejection, cancellation, or disposal before durability reports the discard, clears the claim, and leaves the Surface open. The Dock never interprets queue-row disappearance as either outcome: it re-reads `getActive`; `pending.phase: 'claiming'` stays disabled, `pending: null` restores the draft, and `not-open` closes the Dock. `getActive` joins the log-derived active occurrence with this one process-local pending record. The record is coordination state, not a second durable authority; after a Host restart, an uncommitted claim is absent and the still-open logged Surface becomes editable again.
 

+ 2 - 14
.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md

@@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource {
 }
 ```
 
-`session/queue` 协议条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息:
-
-```ts ignore-check
-interface QueuedMessage {
-  id: InboxItemId
-  messageId: MessageId
-  placement: 'queued' | 'steering'
-  source: MessageSource
-  content: readonly ContentBlock[]
-  preview: string
-  text: string | null
-}
-```
+标准 `inbox` 投影已经在原始 `next-turn` 或 `next-step` 列表中携带每条完整 `UserMessage`,包括 `MessageId`、source 与 content。客户端会把该值保存在通用 projection store 中,因此本提案不需要扩展 queue 传输,也不需要第二种待处理消息类型。
 
 浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`,以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。
 
@@ -198,7 +186,7 @@ interface QueuedMessage {
 
 客户端边界上的提交具有事务性。接纳成功会返回处于 `queued` 阶段的确切 `messageId`;在 `queued` 和 `claiming` 两个阶段中,Dock 会禁用所有变更,并且只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId` 并返回第一次调用的结果;只要第一次提交仍在处理中,另一个提交 ID 就会收到 `submission-pending`。对于一个已接受的 Surface,Host 只会接纳一条用户消息。
 
-Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 从队列取出该调用实例进行普通提示词接纳时,服务会先同步把同一份待处理记录改为 `claiming`,然后 ApiProxy 才发布不再包含已认领行的普通队列快照。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。
+Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 为普通提示词接纳认领该消息时,服务会先同步把同一份待处理记录改为 `claiming`,随后持久删除 splice 才会从通用 `inbox` 投影移除该消息。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。
 
 匹配的 `user/message` 会关闭持久投影并清除认领状态。在持久化之前发生拒绝、取消或 dispose(资源释放)时,系统会报告丢弃、清除认领状态,并让 Surface 保持打开。Dock 绝不会把队列行消失解读为其中任一结果,而会重新读取 `getActive`:`pending.phase: 'claiming'` 会维持禁用状态,`pending: null` 会恢复草稿,`not-open` 会关闭 Dock。`getActive` 会把由日志推导的活动调用实例与这唯一一份进程内待处理记录合并。该记录属于协调状态,不是第二个持久权威来源;Host 重启后,未提交的认领状态不复存在,日志中仍然打开的 Surface 会恢复为可编辑状态。
 

+ 75 - 0
apps/web/tests/cold-blank-session.e2e.ts

@@ -0,0 +1,75 @@
+/** Pending Inbox recovery from detached persistence through the shipped Web profile. */
+
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import {
+  captureStableAria, compareOrRefreshGolden, launchWebScaffold,
+  watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SESSION_ID = SessionId('cold-inbox-web-e2e')
+const PENDING_TEXT = 'Accepted before the Host restarted'
+const EXPECTED = fileURLToPath(new URL('./expected/cold-blank-session/queue.expected.md', import.meta.url))
+
+describe('web e2e: cold Inbox recovery', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    const createdAt = Date.now() - 60_000
+    const handle = await scaffold.ctx.sessionPersistence.create({
+      version: SESSION_FORMAT_VERSION, id: SESSION_ID, createdAt,
+      cwd: scaffold.workspaceCwd, isSeeded: false, delegationDepth: 0,
+    })
+    try {
+      await handle.append([{
+        type: 'agent/inbox/spliced', seq: SessionSeq(0), time: createdAt,
+        data: { target: 'next-turn', start: 0, inserted: [createUserMessage({
+          content: [{ type: 'text', text: PENDING_TEXT }], source: { kind: 'user' },
+        })] },
+      }])
+    } finally {
+      await handle.close()
+    }
+    expect(scaffold.ctx.agents.get(SESSION_ID)).toBeUndefined()
+    expect(scaffold.ctx.sessions.get(SESSION_ID)).toBeUndefined()
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('restores a pending row on opening and reload, then edits and removes it', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-cold-inbox'))
+    const group = page.locator('[role="treeitem"]').first()
+    await group.waitFor({ timeout: 15_000 })
+    await group.click()
+    await page.locator('[role="treeitem"]').nth(1).click()
+    const dock = page.locator('[data-queue-dock]')
+    await dock.getByText(PENDING_TEXT, { exact: true }).waitFor({ timeout: 15_000 })
+    await compareOrRefreshGolden(EXPECTED,
+      await captureStableAria(page, '[data-queue-dock]', scaffold.workspaceCwd), webSnapshotMode())
+    await page.reload({ waitUntil: 'load' })
+    await dock.getByText(PENDING_TEXT, { exact: true }).waitFor({ timeout: 15_000 })
+    await dock.getByRole('button', { name: 'Edit queued message', exact: true }).click()
+    await dock.getByRole('textbox').fill('Edited after recovery')
+    await dock.getByRole('button', { name: 'Save queued message', exact: true }).click()
+    await dock.getByText('Edited after recovery', { exact: true }).waitFor()
+    await dock.getByRole('button', { name: 'Remove queued message', exact: true }).click()
+    await dock.waitFor({ state: 'detached' })
+    expect(tripwire.pageErrors).toEqual([])
+  })
+})

+ 9 - 0
apps/web/tests/expected/cold-blank-session/queue.expected.md

@@ -0,0 +1,9 @@
+- list:
+  - listitem:
+    - text: Accepted before the Host restarted
+    - button "Edit queued message":
+      - img
+    - button "Remove queued message":
+      - img
+    - button "Steer queued message" [disabled]:
+      - img

+ 3 - 1
apps/web/tests/workspace-recency.e2e.ts

@@ -130,7 +130,9 @@ describe('web e2e: workspace recency', () => {
     await socket.close()
     await expect.poll(() => releaseWorkspace !== undefined).toBe(true)
     const workspaceTitle = basename(scaffold.workspaceCwd)
-    await page.getByRole('treeitem').filter({ has: page.getByText(workspaceTitle, { exact: true }) }).hover()
+    // Session titles can fall back to the workspace name while reconnect projections reload.
+    await page.locator('[role="treeitem"][aria-expanded]')
+      .filter({ has: page.getByText(workspaceTitle, { exact: true }) }).hover()
     await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click()
     await pick('In one list')
     await expect.poll(titles).toEqual(['New Session', ...TITLES])

+ 1 - 0
apps/web/tsconfig.json

@@ -90,6 +90,7 @@
     "tests/markdown-inline-code-links.e2e.ts",
     "tests/clickable-links-gallery.e2e.ts",
     "tests/queue-actions.e2e.ts",
+    "tests/cold-blank-session.e2e.ts",
     "tests/queue-image.e2e.ts",
     "tests/skill-invocation-policy.e2e.ts",
     "tests/skill-user-invoke.e2e.ts",

+ 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: 8609ff8d7147ed5c518773ec4749f0f23eb12d57
-architecture.zh.md: 368d7c7903b305df48e5797593802bef0a768d7f
+architecture.md: 37aaf83e37fb5cb7e3df4bd6ed034ccd42103919
+architecture.zh.md: 084fa76a045a9ae744228e8e43900f174b26ae9e

+ 1 - 1
docs/architecture.md

@@ -108,7 +108,7 @@ turn/end
 
 `turn/*`, `step/*`, `system/message`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's 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.
+One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
 
 `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
 

+ 1 - 1
docs/architecture.zh.md

@@ -112,7 +112,7 @@ turn/end
 
 `turn/*`、`step/*`、`system/message`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
 
-输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒
+输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取
 
 `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
 

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: d85e70eb1c6ee7a3db834fea476256755a0f025f
-config-catalog.zh.md: 0d9f2603bea97a76eab2dfdaecc72fa832bd4238
+config-catalog.md: 219373c5899b374167ad36ac4726eddbf0c4096d
+config-catalog.zh.md: a9a07b97da99ee5ce6b5666255dd07ea0080f5ae

+ 1 - 1
docs/config-catalog.md

@@ -111,7 +111,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md)
 
-Source: [`packages/core/agent-loop/src/index.ts:317`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts)
 
 <a id="deepseek-aidsh-agent-presets"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -113,7 +113,7 @@ export interface Config {
 
 依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md)
 
-来源:[`packages/core/agent-loop/src/index.ts:317`](../packages/core/agent-loop/src/index.ts)
+来源:[`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts)
 
 <a id="deepseek-aidsh-agent-presets"></a>
 

+ 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: 3094730a1711409af356c6b01cbc0f8e276240bd
-event-producer-consumer.zh.md: a1ae2b199d428b1a9cda49f07af3dcb95f3ac9b2
+event-producer-consumer.md: 51e5cef531312f4a0d70eb78afcd7f027c951a3b
+event-producer-consumer.zh.md: 441a07976ab42d2fb068800df004d055f7feacdd

+ 6 - 6
docs/event-producer-consumer.md

@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:245`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../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:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
 | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`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), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../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:381`](../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:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../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:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 6 - 6
docs/event-producer-consumer.zh.md

@@ -9,7 +9,7 @@
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:245`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../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:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
 | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`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), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -23,11 +23,11 @@
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../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:381`](../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:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../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:89`](../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: e5a155854a0a5ecf7b663087bf45f78aeb79297d
-core.zh.md: 24aebd77f54e2b2c0c94e2a044d51b2b14e83f6f
+core.md: bba024e8ee4cb89ceac4698bf66f060c7f74dcd6
+core.zh.md: 2e04d5a9a078f3411b2a0d338835ec7d71296f10

+ 1 - 1
docs/subsystems/core.md

@@ -272,7 +272,7 @@ interface Inbox {
 type InboxTarget = 'next-turn' | 'next-step'
 ```
 
-Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. The structural `Inbox` methods record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. At a step boundary, dsh-agent-loop's package-internal `ReactLoopInbox` removes the proposed batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without discarded notifications, then emits per-message claimed notifications. Loop-only pending detection and claiming are not part of `Agent.inbox`. Each `ReactLoopInbox` constructor contributes the standard `inbox` projection from its agent scope; the registry shares that definition across agents by reference count, and its cell is the sole live state while the same fold serves cold consumers. The fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications.
+Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. The structural `Inbox` methods record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. At a step boundary, dsh-agent-loop's package-internal `ReactLoopInbox` removes the proposed batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without discarded notifications, then emits per-message claimed notifications. Loop-only pending detection and claiming are not part of `Agent.inbox`. The `AgentLoop` service registers the standard `inbox` projection before publishing its factory; its cell is the sole live state, and the same fold serves cold consumers even when no Agent exists. The fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications.
 
 Cancellation:
 

+ 1 - 1
docs/subsystems/core.zh.md

@@ -276,7 +276,7 @@ interface Inbox {
 type InboxTarget = 'next-turn' | 'next-step'
 ```
 
-每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。结构化 `Inbox` 方法会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。在步骤边界,dsh-agent-loop 包内部的 `ReactLoopInbox` 会通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知,随后逐条发出 claimed 通知。仅供循环使用的待处理检测与领取操作不属于 `Agent.inbox`。每个 `ReactLoopInbox` 构造函数都从其 agent 作用域贡献标准 `inbox` 投影;注册表通过引用计数在多个 agent 之间共享该定义,其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
+每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。结构化 `Inbox` 方法会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。在步骤边界,dsh-agent-loop 包内部的 `ReactLoopInbox` 会通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知,随后逐条发出 claimed 通知。仅供循环使用的待处理检测与领取操作不属于 `Agent.inbox`。`AgentLoop` 服务在发布工厂之前注册标准 `inbox` 投影;其 cell 是唯一 live 状态,同一份折叠在没有 Agent 时也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
 
 取消:
 

+ 2 - 2
docs/subsystems/session-projection.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/session-projection.md
-session-projection.md: 8568b6df94b1427a341568777c64c82d74176a17
-session-projection.zh.md: 5e50341a6443efde2a3b49b7f5185bc44896efc6
+session-projection.md: cb3ac37d593a2b5aae6725854c31a3a39ae3152b
+session-projection.zh.md: 5051dab59316840267d16d201a17717742565059

+ 1 - 1
docs/subsystems/session-projection.md

@@ -67,7 +67,7 @@ interface ProjectionDefinition<
 }
 ```
 
-The whole-value event rule is load-bearing: a state-carrying log event carries the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
+Every served projection value is a complete read model. A source event may carry a whole value or a domain-owned operation; the unit's deterministic `apply` owns replay, and checkpoint plus forward tail replay reconstructs the same state.
 
 ## The snapshot and the change feed
 

+ 1 - 1
docs/subsystems/session-projection.zh.md

@@ -67,7 +67,7 @@ interface ProjectionDefinition<
 }
 ```
 
-全量值事件规则是承重结构:携带状态的日志事件携带的是变更后的完整状态,绝不是裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)
+每个对外投影值都是完整读模型。源事件可以携带完整值,也可以携带领域拥有的操作;单元的确定性 `apply` 负责回放,checkpoint 加前向 tail replay 会重建出同一状态
 
 ## 快照与变更流
 

+ 2 - 2
docs/subsystems/session.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/session.md
-session.md: 3e4072cc5c11fabd669d152214ef02fb031ef61c
-session.zh.md: 3d3aee700e100d6356abebdc9e982bb234a51d93
+session.md: 7c7e244bfca17c8e22cabe45fc69126113b685d6
+session.zh.md: fecae92809bdba93588e163447d372c7a42d0260

+ 2 - 2
docs/subsystems/session.md

@@ -856,11 +856,11 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
 @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue>
 
 /**
- * Mutate one still-pending queue occurrence on a live Agent.
+ * Mutate one still-pending queue occurrence, resuming a cold Agent first.
  * @param request - Session, queue item, and requested mutation.
  * @returns acknowledgement that the queue mutation was applied.
  */
-@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue
+@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue>
 
 /**
  * Cancel one active Agent turn without dropping its pending inbox.

+ 2 - 2
docs/subsystems/session.zh.md

@@ -860,11 +860,11 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
 @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue>
 
 /**
- * Mutate one still-pending queue occurrence on a live Agent.
+ * Mutate one still-pending queue occurrence, resuming a cold Agent first.
  * @param request - Session, queue item, and requested mutation.
  * @returns acknowledgement that the queue mutation was applied.
  */
-@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue
+@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue>
 
 /**
  * Cancel one active Agent turn without dropping its pending inbox.

+ 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: 1e8089b19c2dcba4691345b44498a37b33670b64
-README.zh.md: ce406f16edea8b47bd584adfa68efea7987510c4
+README.md: 772cec4f15da7150617b847824070b27749394ae
+README.zh.md: 0f04e539d3eb389273b7b7950acf2dadcc5410bc

File diff suppressed because it is too large
+ 0 - 0
packages/api/session-controller/README.md


File diff suppressed because it is too large
+ 0 - 0
packages/api/session-controller/README.zh.md


+ 5 - 1
packages/api/session-controller/src/agent.ts

@@ -197,7 +197,11 @@ export class ApiSessionAgentController {
       this.resumes.set(sessionId, resume)
     }
     try {
-      return { agent: await resume }
+      const agent = await resume
+      // A shared resume can publish an identity that subagent routing adopts
+      // before every waiter observes it; apply the live ownership policy again.
+      const published = this.liveAgent(sessionId)
+      return published ?? { agent }
     } catch (error: unknown) {
       if (error instanceof ApiSessionNotFound) {
         return { error: new RemoteError('session/not-found', error.message, { sessionId }) }

+ 0 - 15
packages/api/session-controller/src/client/contract/snapshot.ts

@@ -1,24 +1,10 @@
 /** Session-owned observable state excluding Conversation target data. */
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionRequestId } from '../../types.ts'
 
-/** One transient inbox occurrence from the authoritative queue snapshot. */
-export interface QueuedMessage {
-  readonly id: MessageId
-  readonly messageId: MessageId
-  readonly placement: 'queued' | 'steering' | 'context'
-  /** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */
-  readonly rpcId?: SessionRequestId
-  readonly content: readonly ContentBlock[]
-  readonly preview: string
-  readonly text: string | null
-}
-
 /** One image displayed by a local submission echo before durable admission. */
 export interface PendingSubmissionImage {
   /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */
@@ -82,7 +68,6 @@ export interface PromptError {
 /** Immutable Session lifecycle and control snapshot. */
 export interface SessionSnapshot {
   readonly sessionId: SessionId
-  readonly queue: readonly QueuedMessage[]
   /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */
   readonly pendingSubmissions: readonly PendingSubmission[]
   readonly running: boolean

+ 11 - 5
packages/api/session-controller/src/client/index.ts

@@ -2,7 +2,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-agent/types'
-import type {} from '@deepseek-ai/dsh-client-connection/client'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import type {} from '@deepseek-ai/dsh-client-file-upload/client'
 import { createSessionControlStream } from './transport.ts'
 import { ClientSessions } from './sessions/service.ts'
@@ -70,7 +70,6 @@ export type {
   PendingSubmissionImageAttachment,
   PendingSubmissionPlacement,
   PromptError,
-  QueuedMessage,
   SessionSnapshot,
 } from './contract/snapshot.ts'
 
@@ -98,6 +97,7 @@ export const inject = [
  */
 export function apply(ctx: Context): void {
   const remotes = ctx.remote as unknown as SessionRemotes
+  const connection = ctx.get('connection') as ConnectionHandle
   const sessions = new ClientSessions(ctx, remotes)
   ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
   ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
@@ -115,9 +115,15 @@ export function apply(ctx: Context): void {
     accept: (frame) => { sessions.handleControlFrame(frame) },
     failed: (error) => { console.error('[session-controller] control stream failed:', error) },
   })
-  control.start()
-  ctx.on('connection/reset', () => { sessions.handleConnected() })
-  if (ctx.remote.$host.home !== undefined) sessions.handleConnected()
+  const connected = (): void => {
+    if (connection.generation.getSnapshot() === undefined) return
+    // A ready control baseline may arrive before Cordis delivers connection/reset.
+    sessions.handleConnected()
+    control.restart()
+    control.start()
+  }
+  ctx.effect(() => connection.generation.subscribe(connected), 'session-controller.client.generation')
+  connected()
   ctx.typert.contexts.registerClient('agent', {
     identity: candidate => sessions.scopeOf(candidate),
     resolve: sessionId => sessions.resolveAgentScope(sessionId),

+ 17 - 31
packages/api/session-controller/src/client/sessions/manager.ts

@@ -8,7 +8,6 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type {
   SessionControlBaseline,
   SessionControlFrame,
-  SessionQueuedItem,
   SessionSummary,
   SessionJob as JobView,
 } from '../../types.ts'
@@ -96,8 +95,6 @@ export class SessionManager {
   private readonly sessions = new Map<SessionId, Session>()
   /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
   private readonly sessionDisposals = new Set<Promise<void>>()
-  /** Latest transient queues, retained independently of Session object materialization. */
-  private readonly queues = new Map<SessionId, readonly SessionQueuedItem[]>()
   /**
    * Sessions that finished running while not selected — the sidebar's green
    * "done" reminder (manager-owned, survives connection generations; cleared
@@ -117,7 +114,7 @@ export class SessionManager {
   private listPhase: SessionListPhase = 'pending'
   private listError: RemoteFailure | null = null
   private listInflight: Promise<void> | null = null
-  /** Mutations arriving after a list request starts are replayed over its response. */
+  /** Active list request's mutation log; its identity also fences completion after reconnect. */
   private listMutations: SessionListMutation[] | null = null
   private readonly addresses = new Map<SessionId, SubagentAddress>()
   private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
@@ -290,11 +287,6 @@ export class SessionManager {
     if (session === undefined) {
       session = this.createSession(sessionId)
       this.sessions.set(sessionId, session)
-      // Install the latest control baseline before the running-bit sync: a
-      // not-running summary must sweep replayed queue
-      // rows the same way a live status flip would (their retirement events dropped
-      // while the session was uninstantiated).
-      session.replaceControl(this.queues.get(sessionId) ?? [])
       // Sync the running and blank bits from the list snapshot into the new
       // instance (consistency when the list precedes open).
       const summary = this.summaries.find(s => s.sessionId === sessionId)
@@ -449,7 +441,7 @@ export class SessionManager {
 
   // ---- List API ----
 
-  /** Full refresh via session.list (single-flight: an in-flight call is reused). */
+  /** Full refresh via session.list (single-flight within one Host generation). */
   refreshList(): Promise<void> {
     if (this.listInflight !== null) return this.listInflight
     this.listState = 'loading'
@@ -461,6 +453,7 @@ export class SessionManager {
     this.listInflight = (async () => {
       try {
         const result = await this.remote.session.list({})
+        if (this.listMutations !== mutations) return
         if (result.ok) {
           const baseline: SessionSummary[] = this.listPhase === 'pending'
             ? [...result.value.items]
@@ -510,12 +503,15 @@ export class SessionManager {
         }
       } catch (error) {
         if (!isRemoteFailure(error)) throw error
+        if (this.listMutations !== mutations) return
         this.listState = 'error'
         this.listError = error
       } finally {
-        this.listMutations = null
-        this.listInflight = null
-        this.notifier.markDirty()
+        if (this.listMutations === mutations) {
+          this.listMutations = null
+          this.listInflight = null
+          this.notifier.markDirty()
+        }
       }
     })()
     return this.listInflight
@@ -669,22 +665,12 @@ export class SessionManager {
       this.notifier.markDirty()
       return
     }
-    if (frame.type === 'jobs') {
-      if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId)
-      else this.jobsBySession.set(frame.sessionId, frame.jobs)
-      this.notifier.markDirty()
-      return
-    }
-    this.queues.set(frame.sessionId, frame.items)
-    this.sessions.get(frame.sessionId)?.handleControlFrame(frame)
+    if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId)
+    else this.jobsBySession.set(frame.sessionId, frame.jobs)
+    this.notifier.markDirty()
   }
 
   private replaceControlBaseline(baseline: SessionControlBaseline): void {
-    this.queues.clear()
-    for (const [sessionId, items] of Object.entries(baseline.queues)) {
-      this.queues.set(sessionId as SessionId, items)
-    }
-
     this.jobsBySession.clear()
     for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
       if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs)
@@ -693,12 +679,8 @@ export class SessionManager {
     for (const [sessionId, block] of Object.entries(baseline.projections)) {
       const store = this.projectionStore(sessionId as SessionId)
       const asOfSeq = sessionSeqCursor(block.asOfSeq)
-      store.truncate(asOfSeq)
       store.seed({ ...block, asOfSeq })
     }
-    for (const [sessionId, session] of this.sessions) {
-      session.replaceControl(this.queues.get(sessionId) ?? [])
-    }
     this.notifier.markDirty()
   }
 
@@ -738,7 +720,6 @@ export class SessionManager {
     this.updateCatalogActivity(sessionId, false)
     if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false)
     else this.sessions.get(sessionId)?.handleRemoved()
-    this.queues.delete(sessionId)
     this.jobsBySession.delete(sessionId)
     if (!durableSubagent) this.projectionStores.delete(sessionId)
     const inflightCatalog = this.catalogInflight.get(sessionId)
@@ -788,9 +769,14 @@ export class SessionManager {
 
   /**
    * Repair one re-established Host-event generation with queryable baselines.
+   * Discard old projection cuts before new queries, including cold Sessions
+   * absent from the process-local control baseline.
    * Opened Session follow streams resume independently through API Gateway.
    */
   handleConnected(): void {
+    for (const store of this.projectionStores.values()) store.clear()
+    this.listMutations = null
+    this.listInflight = null
     void this.refreshList()
     const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
     if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)

+ 6 - 13
packages/api/session-controller/src/client/sessions/projection-store.ts

@@ -67,9 +67,9 @@ interface Channel {
 /**
  * One session's projection values. Framework semantics, uniform across every
  * key: a baseline seeds rows at its cut, a push frame updates one row, and in
- * both paths a lower-or-equal seq loses — a replayed frame cannot regress a
- * value, a stale baseline cannot overwrite a newer frame. A key the store has
- * never seen reads `undefined` (capability absent). Faces are identity-stable
+ * both paths a lower-or-equal seq within the Host generation loses. A replayed
+ * frame cannot regress a value; a stale baseline cannot overwrite a newer
+ * frame. A key the store has never seen reads `undefined` (capability absent). Faces are identity-stable
  * per key (create-on-demand, cached) so the React side binds each exactly
  * once; the store-level channel (`subscribeAny`) serves coarse consumers (the
  * manager's list projection reads the `title` key).
@@ -159,16 +159,9 @@ export class ProjectionValueStore {
     }
   }
 
-  /**
-   * Drop rows beyond a replacement control baseline. Such rows describe
-   * process state the Host lost before persisting it and would otherwise
-   * outrank recomputed lower-seq values forever. The caller seeds the new
-   * baseline immediately afterward.
-   * @param lastSeq - highest durable sequence reflected by the baseline.
-   */
-  truncate(lastSeq: SessionSeqCursor): void {
-    for (const [key, row] of this.rows) {
-      if (row.seq <= lastSeq) continue
+  /** Discard one Host generation's values and watermarks while preserving subscribed faces. */
+  clear(): void {
+    for (const key of this.rows.keys()) {
       this.rows.delete(key)
       this.changed(key)
     }

+ 0 - 71
packages/api/session-controller/src/client/sessions/queue-mirror.ts

@@ -1,71 +0,0 @@
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { SessionQueuedItem } from '../../types.ts'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { QueuedMessage } from '../contract/snapshot.ts'
-
-const QUEUE_PREVIEW_CHARS = 200
-
-// Attachment blocks are excluded: queue presentation renders them from
-// `content`, so the text preview covers only what has no visual form.
-function previewOf(content: readonly ContentBlock[]): string {
-  const flat = content
-    .filter(block => block.type !== 'image' && block.type !== 'file')
-    .map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
-    .join(' ').replace(/\s+/g, ' ').trim()
-  const chars = Array.from(flat)
-  return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
-}
-
-function textOf(content: readonly ContentBlock[]): string | null {
-  if (!content.every(block => block.type === 'text')) return null
-  return content.map(block => block.text).join('')
-}
-
-type QueueItems = readonly SessionQueuedItem[]
-
-/** Authoritative transient queue projection and durable steering handoff. */
-export class SessionQueueMirror {
-  private current: readonly QueuedMessage[] = []
-
-  /**
-   * Return the current immutable queue projection.
-   * @returns current queue rows.
-   */
-  snapshot(): readonly QueuedMessage[] {
-    return this.current
-  }
-
-  /**
-   * Replace from one authoritative stream queue frame.
-   * @param items - complete host queue snapshot.
-   */
-  replace(items: QueueItems): void {
-    this.current = items.map((item) => {
-      const content = item.message.content as unknown as readonly ContentBlock[]
-      return {
-        id: item.id,
-        messageId: item.message.id,
-        placement: item.placement,
-        ...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }),
-        content,
-        preview: previewOf(content),
-        text: textOf(content),
-      }
-    })
-  }
-
-  /**
-   * Retire a transient steering row once its durable message enters the log.
-   * @param event - newly contiguous durable Session event.
-   * @returns whether the projection changed.
-   */
-  acceptDurable(event: SessionEvent): boolean {
-    if (event.type !== 'user/message') return false
-    const messageId = event.data.id
-    const index = this.current.findIndex(item =>
-      item.placement === 'steering' && item.messageId === messageId)
-    if (index < 0) return false
-    this.current = this.current.filter((_item, candidate) => candidate !== index)
-    return true
-  }
-}

+ 27 - 37
packages/api/session-controller/src/client/sessions/session.ts

@@ -1,6 +1,7 @@
 // Sessions remain resident after creation so their open Remote sources keep running off-screen.
 
 import type { Context } from '@deepseek-ai/cordis'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
@@ -13,9 +14,7 @@ import type {
   QueueAction,
   SessionAddress,
   SessionAssistantStreamBaseline,
-  SessionControlFrame,
   SessionProjectionBaseline,
-  SessionQueuedItem,
   SessionRequestId,
 } from '../../types.ts'
 import type {
@@ -36,7 +35,6 @@ import type { SessionRemotes } from './remotes.ts'
 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,
@@ -99,8 +97,7 @@ export class Session implements SessionFace {
   private jumpTargetSeq: SessionSeq | null = null
   /** The running jump loop's completion, shared by retargeting callers. */
   private jumpPromise: Promise<void> | null = null
-  /** Authoritative stream-only inbox snapshot; pending work never hits history. */
-  private readonly queueMirror = new SessionQueueMirror()
+  private readonly stopObservingInbox: () => void
   private readonly assistantStream = new ClientAssistantStream()
   private running = false
   private address: SubagentAddress | undefined
@@ -121,7 +118,7 @@ export class Session implements SessionFace {
   /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */
   private pendingSubmissions: readonly PendingSubmission[] = []
   /** Per-echo settlement state; `retiring` latches the first observation so a
-   *  queue frame and its durable event cannot both retire one echo. */
+   *  Inbox projection and its durable event cannot both retire one echo. */
   private readonly submissionSettlements = new Map<SessionRequestId, {
     readonly onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined
     retiring: boolean
@@ -173,6 +170,9 @@ export class Session implements SessionFace {
       this.snapshotCache = this.buildSnapshot()
     })
     this.snapshotCache = this.buildSnapshot()
+    this.stopObservingInbox = this.projections.faceOf('inbox').subscribe(() => {
+      this.observeSubmissionInbox()
+    })
   }
 
   /**
@@ -453,7 +453,7 @@ export class Session implements SessionFace {
   }
 
   /** Rebuild an opened history source after address replacement.
-   *  Invalidates any in-flight open first; queue state belongs to the independently
+   *  Invalidates any in-flight open first; projection state belongs to the independently
    *  reconnecting control stream and remains untouched. */
   async resync(): Promise<void> {
     if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
@@ -491,26 +491,6 @@ export class Session implements SessionFace {
 
   // ---- Manager-only entry points (@internal; never called by the UI) ----
 
-  /**
-   * Replace every transient control value for this Session from one stream baseline.
-   * @param queue - complete pending queue for this Session.
-   */
-  replaceControl(queue: readonly SessionQueuedItem[]): void {
-    this.queueMirror.replace(queue)
-    this.observeSubmissionQueue(queue)
-    this.notifier.markDirty()
-  }
-
-  /**
-   * Apply one Session-addressed live control update.
-   * @param frame - queue replacement addressed to this Session.
-   */
-  handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
-    this.queueMirror.replace(frame.items)
-    this.observeSubmissionQueue(frame.items)
-    this.notifier.markDirty()
-  }
-
   /**
    * Running-bit relay from the host stream (list entry and snapshot stay consistent).
    * @param running - the new running state.
@@ -588,6 +568,7 @@ export class Session implements SessionFace {
    * @returns when the Remote iterator has completed teardown.
    */
   async dispose(): Promise<void> {
+    this.stopObservingInbox()
     // Unsettled echoes retire as failed so their owners can restore or
     // release browser resources; echoes already scheduled as observed keep
     // that settlement.
@@ -713,18 +694,25 @@ export class Session implements SessionFace {
     const event = entry.event
     const awaitingFirstTurn = this.firstPromptPendingTurn
     if (event.type === 'turn/start') this.firstPromptPendingTurn = false
-    const queueChanged = this.queueMirror.acceptDurable(event)
     this.eventSource.append(entry)
     // After the feed append: the conversation assembly's animation frame is
     // registered by the feed subscribers above, so the echo-retirement frame
     // scheduled here always runs after the durable node became renderable.
     this.observeSubmissionEvent(event)
-    return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn
+    return awaitingFirstTurn !== this.firstPromptPendingTurn
   }
 
-  /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */
+  /** Observe durable acceptance even when insertion and claim share one projection notification. */
   private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void {
-    if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return
+    if (this.submissionSettlements.size === 0) return
+    if (event.type === 'agent/inbox/spliced') {
+      const splice = event.data as { readonly inserted?: unknown } | undefined
+      if (Array.isArray(splice?.inserted)) {
+        for (const message of splice.inserted) this.observeSubmissionEvent({ type: 'user/message', data: message })
+      }
+      return
+    }
+    if (event.type !== 'user/message') return
     // Structural read: window entries may be compact history records, so the
     // fields are narrowed rather than trusted (same posture as Conversation
     // assembly matchers).
@@ -734,12 +722,15 @@ export class Session implements SessionFace {
     this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content))
   }
 
-  /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
-  private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void {
+  /** Retire local echoes when their accepted messages appear in the durable Inbox projection. */
+  private observeSubmissionInbox(): void {
     if (this.submissionSettlements.size === 0) return
-    for (const item of items) {
-      if (item.rpcId !== undefined) {
-        this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content))
+    const inbox = this.projections.get('inbox') as InboxState | undefined
+    if (inbox === undefined) return
+    for (const message of [...inbox['next-turn'], ...inbox['next-step']]) {
+      const source = message.source
+      if (source.kind === 'user' && 'rpcId' in source) {
+        this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content))
       }
     }
   }
@@ -795,7 +786,6 @@ export class Session implements SessionFace {
   private buildSnapshot(): SessionSnapshot {
     return {
       sessionId: this.sessionId,
-      queue: this.queueMirror.snapshot(),
       pendingSubmissions: this.pendingSubmissions,
       running: this.running,
       subagent: this.address === undefined

+ 9 - 4
packages/api/session-controller/src/commands.ts

@@ -414,11 +414,11 @@ export class SessionCommandController {
   }
 
   /**
-   * Mutate one still-pending queue occurrence without resuming a cold Agent.
+   * Mutate one pending Inbox occurrence, restoring an ordinary cold Agent when needed.
    * @param request - Session, queue item, and requested mutation.
    * @returns acknowledgement that the queue mutation was applied.
    */
-  updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
+  async updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue> {
     if (request.action.kind === 'edit') {
       if (request.action.content.some(block => block.type !== 'text')) {
         throw new RemoteError(
@@ -435,9 +435,14 @@ export class SessionCommandController {
         )
       }
     }
-    const agent = this.ctx.agents.get(request.sessionId)
+    let agent = this.ctx.agents.get(request.sessionId)
     if (agent === undefined) {
-      throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
+      const found = await this.agents.resolveAgent(request.sessionId)
+      if ('error' in found) {
+        if (found.error.code !== 'session/not-found') throw found.error
+        throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
+      }
+      agent = found.agent
     }
     if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
       const identity = this.ctx.sessionProjections

+ 3 - 45
packages/api/session-controller/src/control.ts

@@ -1,11 +1,11 @@
-/** Live Session queue, jobs, and projection state with reconnect baselines. */
+/** Live Session jobs and projection state with reconnect baselines. */
 
 import type { Context } from '@deepseek-ai/cordis'
-import type { Agent, InboxState } from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
 import { Deque } from '@deepseek-ai/dsh-deque'
 import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
 import type {
-  Session, SessionId, UserMessage,
+  Session, SessionId,
 } from '@deepseek-ai/dsh-session'
 import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type {
@@ -14,7 +14,6 @@ import type {
   SessionJob,
   SessionProjectionBaseline,
   SessionProjectionValues,
-  SessionQueuedItem,
 } from './types.ts'
 
 /** Owns the Host-wide Session control stream. */
@@ -31,14 +30,6 @@ export class SessionControlController {
         value: value as JsonValue,
         seq,
       })
-      if (key !== 'inbox') return
-      const agent = this.ctx.agents.get(session.id)
-      if (agent?.session !== session) return
-      this.broadcast({
-        type: 'queue',
-        sessionId: session.id,
-        items: queueItemsFromInbox(value as InboxState),
-      })
     })
     ctx.inject(['jobs'], (jobsCtx) => {
       jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
@@ -73,15 +64,12 @@ export class SessionControlController {
 
   private baseline(): SessionControlBaseline {
     const sessions = this.ctx.sessions.list()
-    const queues = Object.create(null) as Record<SessionId, readonly SessionQueuedItem[]>
     const jobs = Object.create(null) as Record<SessionId, readonly SessionJob[]>
     for (const session of sessions) {
       const agent = this.ctx.agents.get(session.id)
-      queues[session.id] = agent?.session === session ? queueItems(agent) : []
       jobs[session.id] = this.jobsFor(agent)
     }
     return {
-      queues,
       jobs,
       projections: this.projectionBaseline(sessions),
     }
@@ -167,36 +155,6 @@ class ControlQueue {
   }
 }
 
-function queueItems(agent: Agent): SessionQueuedItem[] {
-  return queueItemsFromInbox({
-    'next-turn': agent.inbox.nextTurn,
-    'next-step': agent.inbox.nextStep,
-  })
-}
-
-function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] {
-  return [
-    ...inbox['next-turn'].map(message => ({
-      id: message.id,
-      placement: 'queued' as const,
-      ...promptRpcId(message),
-      message: { id: message.id, content: message.content as unknown as JsonValue[] },
-    })),
-    ...inbox['next-step'].map(message => ({
-      id: message.id,
-      placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
-      ...promptRpcId(message),
-      message: { id: message.id, content: message.content as unknown as JsonValue[] },
-    })),
-  ]
-}
-
-/** Prompt-RPC identity carried by a browser-submitted message's user source. */
-function promptRpcId(message: UserMessage): Pick<SessionQueuedItem, 'rpcId'> {
-  const source = message.source
-  return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {}
-}
-
 function jobView(job: JobSnapshot): SessionJob {
   return {
     id: job.id,

+ 2 - 2
packages/api/session-controller/src/index.ts

@@ -360,12 +360,12 @@ export class SessionController extends TypertRemoteService {
   }
 
   /**
-   * Mutate one still-pending queue occurrence on a live Agent.
+   * Mutate one still-pending queue occurrence, resuming a cold Agent first.
    * @param request - Session, queue item, and requested mutation.
    * @returns acknowledgement that the queue mutation was applied.
    */
   @Remote('updateQueue')
-  updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
+  updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue> {
     return this.commands.updateQueue(request)
   }
 

+ 0 - 15
packages/api/session-controller/src/types.ts

@@ -525,19 +525,6 @@ export type SessionFollowFrame =
   | SessionEventEntry
   | { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
 
-/** One pending inbox occurrence in the authoritative queue snapshot. */
-export interface SessionQueuedItem {
-  readonly id: MessageId
-  readonly placement: 'queued' | 'steering' | 'context'
-  /** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */
-  readonly rpcId?: SessionRequestId
-  /** JSON-safe message fields consumed by pending-queue presentation. */
-  readonly message: {
-    readonly id: MessageId
-    readonly content: readonly JsonValue[]
-  }
-}
-
 /** Browser-safe background-job row. */
 export interface SessionJob {
   readonly id: JobId
@@ -551,7 +538,6 @@ export interface SessionJob {
 
 /** Complete live control baseline emitted once per control stream generation. */
 export interface SessionControlBaseline {
-  readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>
   readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>
   readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>
 }
@@ -567,7 +553,6 @@ export interface SessionProjectionUpdate {
 /** Host-wide live state stream. Each generation starts with exactly one baseline. */
 export type SessionControlFrame =
   | { readonly type: 'baseline'; readonly value: SessionControlBaseline }
-  | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] }
   | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
   | ({ readonly type: 'projection' } & SessionProjectionUpdate)
 

+ 32 - 1
packages/api/session-controller/tests/client-apply.client.spec.ts

@@ -18,7 +18,7 @@ const ROSTER = webApp.closure([SELF])
 const it = createClientTest({ roster: ROSTER })
 const EVENTS = '$events'
 const CONTROL = 'session/control'
-const BASELINE = { type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }
+const BASELINE = { type: 'baseline', value: { jobs: {}, projections: {} } }
 /** The first client boot pays the cold module transform of the cone. */
 const COLD_BOOT_TIMEOUT_MS = 60_000
 
@@ -80,6 +80,37 @@ describe('Session Controller Client apply', () => {
     expect(connected).toHaveBeenCalledTimes(2)
   })
 
+  it('keeps immediate control projections when the ready notification follows their baseline', async ({ mock, start }) => {
+    const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
+    const sessionId = sid('immediate-baseline')
+    mock.remote.session.list.mockResolvedValue(ok({ items: [{
+      sessionId, updatedAt: 1, running: false, blank: false,
+    }] }))
+    let projection = { asOfSeq: 20, values: { title: 'Before restart' } }
+    mock.stream(CONTROL, (_args, stream) => {
+      stream.push({ type: 'baseline', value: { jobs: {}, projections: { [sessionId]: projection } } })
+    })
+    const { client, sessions } = await bench(start)
+    await vi.waitFor(() => {
+      expect(sessions.list.getSnapshot().byId[sessionId]?.title).toBe('Before restart')
+    })
+    client.ctx.emit('connection/reset')
+    await client.flush()
+    expect(sessions.list.getSnapshot().byId[sessionId]?.title).toBe('Before restart')
+
+    projection = { asOfSeq: 1, values: { title: 'After restart' } }
+    client.connection.reconnect()
+    await vi.waitFor(() => {
+      expect(sessions.list.getSnapshot().byId[sessionId]?.title).toBe('After restart')
+    })
+
+    await client.unload(SELF)
+    client.connection.reconnect()
+    await mock.streams.opened(EVENTS, 3)
+    await vi.waitFor(() => { expect(client.connection.generation.getSnapshot()?.id).toBe(3) })
+    expect(connected).toHaveBeenCalledTimes(2)
+  })
+
   it('accepts the control baseline, retries a carrier loss once, and reports a second opening snapshot as a protocol failure', async ({ mock, start }) => {
     const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
     const logged = vi.spyOn(console, 'error').mockImplementation(() => {})

+ 29 - 9
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -1,3 +1,4 @@
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
@@ -93,7 +94,9 @@ async function commandHarness(
     assembled: undefined,
   }
   const agents = {
-    resolveAgent: () => Promise.resolve({ agent }),
+    resolveAgent: (id: SessionId) => Promise.resolve(id === agent.id
+      ? { agent }
+      : { error: new RemoteError('session/not-found', 'missing', { sessionId: id }) }),
     selectionFor: () => selection,
     serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
     composeAgent: () => Promise.resolve({ setup: () => {} }),
@@ -113,6 +116,23 @@ async function expectFailure(operation: Promise<unknown>, code: string): Promise
 }
 
 describe('Session queue commands', () => {
+  it('preserves the cold Agent resolver rejection', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    const error = new RemoteError('session/agent-busy', 'owned by a child', { reason: 'subagent-owned' })
+    const controller = new SessionCommandController(ctx, {
+      resolveAgent: () => Promise.resolve({ error }),
+    } as unknown as ApiSessionAgentController, '/workspace')
+    try {
+      await expect(controller.updateQueue({
+        sessionId: SessionId('cold-child'), itemId: MessageId('pending'), action: { kind: 'remove' },
+      })).rejects.toBe(error)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('edits, removes, steers, and rejects stale queue occurrences', async () => {
     const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness()
     const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
@@ -155,7 +175,7 @@ describe('Session queue commands', () => {
     await expectFailure(Promise.resolve().then(() => controller.updateQueue({
       sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
     })), 'session/steer-unavailable')
-    expect(controller.updateQueue({
+    expect(await controller.updateQueue({
       sessionId: agent.id,
       itemId: queued.id,
       action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
@@ -164,14 +184,14 @@ describe('Session queue commands', () => {
     // An edit rewrites content in place, so the occurrence a client addressed
     // by id stays addressable.
     expect(inbox.nextTurn[0]?.id).toBe(queued.id)
-    expect(controller.updateQueue({
+    expect(await controller.updateQueue({
       sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
     })).toEqual({ accepted: true })
 
     Object.assign(agent, { status: 'running' })
     const steered = inbox.nextTurn[0]
     if (steered === undefined) throw new Error('missing edited queue item')
-    expect(controller.updateQueue({
+    expect(await controller.updateQueue({
       sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' },
     })).toEqual({ accepted: true })
     expect(steer).toHaveBeenCalledWith(steered)
@@ -184,7 +204,7 @@ describe('Session queue commands', () => {
       source: { kind: 'user', rpcId: 'file-rpc' as never },
     })
     inbox.append('next-turn', queuedFile)
-    expect(controller.updateQueue({
+    expect(await controller.updateQueue({
       sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' },
     })).toEqual({ accepted: true })
     expect(steer).toHaveBeenLastCalledWith(queuedFile)
@@ -214,7 +234,7 @@ describe('Session queue commands', () => {
       inbox.append('next-turn', queued)
       inbox.append('next-step', context)
 
-      expect(controller.updateQueue({
+      expect(await controller.updateQueue({
         sessionId: agent.id,
         itemId: context.id,
         action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] },
@@ -226,10 +246,10 @@ describe('Session queue commands', () => {
       })
       expect(editedContext?.id).toBe(context.id)
       if (editedContext === undefined) throw new Error('missing edited context')
-      expect(controller.updateQueue({
+      expect(await controller.updateQueue({
         sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
       })).toEqual({ accepted: true })
-      expect(controller.updateQueue({
+      expect(await controller.updateQueue({
         sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
       })).toEqual({ accepted: true })
       expect(steer).toHaveBeenCalledWith(queued)
@@ -251,7 +271,7 @@ describe('Session queue commands', () => {
     // command must accept whichever boundary `Agent.steer()` selects.
     steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) })
 
-    expect(controller.updateQueue({
+    expect(await controller.updateQueue({
       sessionId: agent.id, itemId: first.id, action: { kind: 'steer' },
     })).toEqual({ accepted: true })
     expect(steer).toHaveBeenCalledWith(first)

+ 1 - 1
packages/api/session-controller/tests/commands-upload-file.host.spec.ts

@@ -426,7 +426,7 @@ describe('Session file uploads', () => {
     await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
     const queued = followup.mock.calls[0]?.[0] as UserMessage
     agent.inbox.append('next-turn', queued)
-    expect(controller.updateQueue({
+    expect(await controller.updateQueue({
       sessionId: SESSION,
       itemId: queued.id,
       action: { kind: 'remove' },

+ 31 - 41
packages/api/session-controller/tests/control-queue.host.spec.ts

@@ -1,5 +1,5 @@
 import { Context } from '@deepseek-ai/cordis'
-import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
+import type { Agent, Inbox, InboxState } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import { afterEach, describe, expect, it } from 'vitest'
@@ -37,15 +37,15 @@ function message(text: string, source: 'user' | 'plugin' = 'user') {
   })
 }
 
-describe('Session control queue projection', () => {
-  /** Consume frames until the next queue replacement (inbox projection frames interleave). */
-  async function nextQueueFrame(
+describe('Session control Inbox projection', () => {
+  /** Consume frames until the next durable Inbox value. */
+  async function nextInboxFrame(
     iterator: AsyncIterator<SessionControlFrame>,
-  ): Promise<Extract<SessionControlFrame, { type: 'queue' }>> {
+  ): Promise<Extract<SessionControlFrame, { type: 'projection' }>> {
     for (;;) {
       const next = await iterator.next()
-      if (next.done) throw new Error('stream ended before a queue frame')
-      if (next.value.type === 'queue') return next.value
+      if (next.done) throw new Error('stream ended before an Inbox value')
+      if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value
     }
   }
 
@@ -64,23 +64,21 @@ describe('Session control queue projection', () => {
     expect(opened.value).toMatchObject({
       type: 'baseline',
       value: {
-        queues: {
-          'queue-session': [
-            { id: queued.id, placement: 'queued' },
-            { id: steering.id, placement: 'steering' },
-            { id: context.id, placement: 'context' },
-          ],
+        projections: {
+          'queue-session': { values: { inbox: {
+            'next-turn': [queued], 'next-step': [steering, context],
+          } } },
         },
       },
     })
 
     const replacement = message('replacement')
     inbox.append('next-turn', replacement)
-    const replaced = await nextQueueFrame(iterator)
-    expect(replaced.items.map(item => item.id)).toContain(replacement.id)
+    const replaced = await nextInboxFrame(iterator)
+    expect(replaced.value).toMatchObject({ 'next-turn': [queued, replacement] })
     inbox.remove(steering.id)
-    const removed = await nextQueueFrame(iterator)
-    expect(removed.items.map(item => item.id)).not.toContain(steering.id)
+    const removed = await nextInboxFrame(iterator)
+    expect(removed.value).toMatchObject({ 'next-step': [context] })
 
     abort.abort()
     await iterator.next()
@@ -108,9 +106,6 @@ describe('Session control queue projection', () => {
         value: { 'next-turn': [{ id: pending.id }], 'next-step': [] },
       },
     })
-    await expect(nextQueueFrame(iterator)).resolves.toMatchObject({
-      items: [{ id: pending.id, placement: 'queued' }],
-    })
 
     abort.abort()
     await iterator.next()
@@ -129,33 +124,28 @@ describe('Session control queue projection', () => {
     const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
     const opened = await iterator.next()
     if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline')
-    const items = opened.value.value.queues['queue-session' as SessionId] ?? []
-    expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([
-      { id: identified.id, placement: 'queued', rpcId: 'req-42' },
-      { id: items[1]?.id, placement: 'steering', rpcId: undefined },
-    ])
-    expect('rpcId' in (items[1] ?? {})).toBe(false)
+    const inboxValue = opened.value.value.projections['queue-session' as SessionId]?.values.inbox as unknown as InboxState
+    expect(inboxValue['next-turn'][0]?.source).toMatchObject({ kind: 'user', rpcId: 'req-42' })
+    expect(inboxValue['next-step'][0]?.source).toEqual({ kind: 'user' })
 
     abort.abort()
     await iterator.next()
   })
 
-  it('ignores inbox events without the exact live Agent session', async () => {
-    const { ctx, control, agent, inbox } = await harness()
+  it('publishes Inbox values for sessions without a live Agent', async () => {
+    const { ctx, control } = await harness()
     const abort = new AbortController()
     const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
     await iterator.next()
-
-    const unrelated = ctx.sessions.create(SessionId('unrelated-queue'))
-    unrelated.append('agent/inbox/spliced', {
-      target: 'next-turn',
-      start: 0,
-      inserted: [message('unrelated')],
+    const session = ctx.sessions.create(SessionId('unattached-inbox'))
+    const pending = message('unattached')
+    session.append('agent/inbox/spliced', {
+      target: 'next-turn', start: 0, inserted: [pending],
+    })
+    expect(ctx.agents.get(session.id)).toBeUndefined()
+    await expect(nextInboxFrame(iterator)).resolves.toMatchObject({
+      sessionId: session.id, value: { 'next-turn': [pending], 'next-step': [] },
     })
-    const replacement = ctx.sessions.create(SessionId('replacement-session'))
-    Object.defineProperty(agent, 'session', { configurable: true, value: replacement })
-    inbox.append('next-turn', message('wrong-session'))
-
     abort.abort()
     await iterator.next()
   })
@@ -183,14 +173,14 @@ describe('Session control queue projection', () => {
     inbox.append('next-turn', first)
     inbox.append('next-turn', second)
 
-    const queues: Extract<SessionControlFrame, { type: 'queue' }>[] = []
+    const values: InboxState[] = []
     ownedContexts.delete(ctx)
     await ctx.fiber.dispose()
     for (;;) {
       const next = await iterator.next()
       if (next.done) break
-      if (next.value.type === 'queue') queues.push(next.value)
+      if (next.value.type === 'projection' && next.value.key === 'inbox') values.push(next.value.value as unknown as InboxState)
     }
-    expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]])
+    expect(values.map(value => value['next-turn'].map(item => item.id))).toEqual([[first.id], [first.id, second.id]])
   })
 })

+ 172 - 0
packages/api/session-controller/tests/inbox-projection.client.spec.ts

@@ -0,0 +1,172 @@
+/** Inbox projection delivery and queue-operation transport. */
+
+import { describe, expect, onTestFinished } from 'vitest'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { SessionManager } from '../src/client/sessions/manager.ts'
+import { ok } from '@deepseek-ai/dsh-remote-mock'
+import { createClientTest, type ClientTestFixtures, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
+
+const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) })
+
+function makeManager(remote: ClientTestFixtures['remote']): SessionManager {
+  const manager = new SessionManager(remote as unknown as SessionRemotes)
+  onTestFinished(() => manager.dispose())
+  return manager
+}
+
+const SID = 'fk-q1' as SessionId
+const text = (value: string) => [{ type: 'text' as const, text: value }]
+
+let nextSeq = 1
+
+function message(label: string, body: string) {
+  return createUserMessage({
+    content: text(body),
+    source: { kind: 'user', rpcId: `rpc-${label}` } as never,
+  })
+}
+
+function inboxFrame(value: InboxState): Extract<SessionControlFrame, { type: 'projection' }> {
+  return {
+    type: 'projection',
+    sessionId: SID,
+    key: 'inbox',
+    seq: nextSeq++,
+    value: value as never,
+  }
+}
+
+describe('Inbox projection intake', () => {
+  it('stores the complete Agent-owned value without adding queue state to the Session snapshot', ({ remote }) => {
+    const manager = makeManager(remote)
+    const queued = message('queued', 'later')
+    const steering = message('steering', 'now')
+    const value = { 'next-turn': [queued], 'next-step': [steering] }
+
+    manager.handleControlFrame(inboxFrame(value))
+    const session = manager.get(SID)
+
+    expect(session.projections.faceOf('inbox').getSnapshot()).toEqual(value)
+    expect(session.getSnapshot()).not.toHaveProperty('queue')
+  })
+
+  it.for(['included', 'omitted'] as const)(
+    'keeps a newer list Inbox when a delayed control baseline has the key %s',
+    async (key, { remote }) => {
+      const list = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+      remote.session.list.mockReturnValue(list.promise)
+      const manager = makeManager(remote)
+      const empty = { 'next-turn': [], 'next-step': [] }
+      const stale = { ...empty, 'next-turn': [message('removed', 'already removed')] }
+      const result = ok({ items: [{
+        sessionId: SID, updatedAt: 1, running: false, blank: false,
+        projections: { asOfSeq: 21, values: { inbox: empty } },
+      }] })
+      let refreshed: Promise<void> | undefined
+
+      try {
+        manager.handleConnected()
+        refreshed = manager.refreshList()
+        list.resolve(result)
+        await refreshed
+        const face = manager.get(SID).projections.faceOf('inbox')
+        expect(face.getSnapshot()).toEqual(empty)
+
+        manager.handleControlFrame({
+          type: 'baseline',
+          value: { jobs: {}, projections: { [SID]: {
+            asOfSeq: 20, values: key === 'included' ? { inbox: stale } : {},
+          } } },
+        })
+
+        expect(face.getSnapshot()).toEqual(empty)
+      } finally {
+        list.resolve(result)
+        await refreshed
+        await manager.dispose()
+      }
+    },
+  )
+
+  it.for(['control-first', 'list-first'] as const)(
+    'replaces cold Session Inbox values across Host generations (%s)',
+    async (order, { remote }) => {
+      const list = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+      remote.session.list.mockReturnValue(list.promise)
+      const manager = makeManager(remote)
+      const hiddenSessionId = 'cold-hidden-inbox' as SessionId
+      const ghost = message('ghost', 'acceptance was not persisted')
+      const pending = message('pending', 'claim was not persisted')
+      const empty = { 'next-turn': [], 'next-step': [] }
+      const restored = { 'next-turn': [pending], 'next-step': [] }
+      manager.handleControlFrame({ ...inboxFrame({ ...empty, 'next-turn': [ghost] }), seq: 20 })
+      manager.handleControlFrame({ ...inboxFrame(empty), sessionId: hiddenSessionId, seq: 20 })
+      const face = manager.get(SID).projections.faceOf('inbox')
+      const baseline = { type: 'baseline', value: { jobs: {}, projections: {} } } as const
+      const result = ok({ items: [
+        { sessionId: SID, updatedAt: 1, running: false, blank: false,
+          projections: { asOfSeq: 1, values: { inbox: empty } } },
+        { sessionId: hiddenSessionId, updatedAt: 1, running: false, blank: false,
+          projections: { asOfSeq: 1, values: { inbox: restored } } },
+      ] })
+      let refreshed: Promise<void> | undefined
+
+      try {
+        manager.handleConnected()
+        refreshed = manager.refreshList()
+        expect(face.getSnapshot()).toBeUndefined()
+        if (order === 'control-first') manager.handleControlFrame(baseline)
+        list.resolve(result)
+        await refreshed
+        if (order === 'list-first') manager.handleControlFrame(baseline)
+
+        expect(manager.get(SID).projections.faceOf('inbox')).toBe(face)
+        expect(face.getSnapshot()).toEqual(empty)
+        expect(manager.get(hiddenSessionId).projections.faceOf('inbox').getSnapshot()).toEqual(restored)
+      } finally {
+        list.resolve(result)
+        await refreshed
+        await manager.dispose()
+      }
+    },
+  )
+
+  it('retains only the highest-seq value received before Session materialization', ({ remote }) => {
+    const manager = makeManager(remote)
+    manager.handleControlFrame(inboxFrame({
+      'next-turn': [message('old', 'old')],
+      'next-step': [],
+    }))
+    const latest = {
+      'next-turn': [message('latest', 'latest')],
+      'next-step': [],
+    }
+    manager.handleControlFrame(inboxFrame(latest))
+
+    expect(manager.get(SID).projections.faceOf('inbox').getSnapshot()).toEqual(latest)
+  })
+})
+
+describe('queue operation transport', () => {
+  it('does not mutate the Inbox projection before the Host publishes its committed value', async ({ remote }) => {
+    remote.session.updateQueue.mockResolvedValue(ok({ accepted: true }))
+    const manager = makeManager(remote)
+    const pending = message('pending', 'before')
+    const initial = { 'next-turn': [pending], 'next-step': [] }
+    manager.handleControlFrame(inboxFrame(initial))
+    const session = manager.get(SID)
+
+    await expect(session.updateQueue(pending.id, { kind: 'edit', content: text('after') }))
+      .resolves.toEqual({ ok: true, value: { accepted: true } })
+    expect(remote.session.updateQueue).toHaveBeenCalledExactlyOnceWith({
+      sessionId: SID,
+      itemId: pending.id,
+      action: { kind: 'edit', content: text('after') },
+    })
+    expect(session.projections.faceOf('inbox').getSnapshot()).toBe(initial)
+  })
+})

+ 87 - 28
packages/api/session-controller/tests/manager.client.spec.ts

@@ -182,36 +182,27 @@ describe('list lifecycle', () => {
     expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
   })
 
-  it('drops a projection row beyond the subscription baseline before accepting its durable replay', async ({ mock, remote }) => {
+  it('discards the previous generation title before accepting its lower-seq replay', async ({ mock, remote }) => {
     remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
     const manager = makeManager(mock, remote)
-    await manager.refreshList()
-    const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) }
-    frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
-
-    // The durable baseline says the host only knows up to seq 2: the phantom
-    // row rode lost state and must drop, or last-wins pins it forever.
-    frame({
-      type: 'baseline',
-      value: {
-        queues: {}, jobs: {},
-        projections: { [S1]: { asOfSeq: 2, values: {} } },
-      },
-    })
-    expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
-
-    frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 })
-    expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
+    try {
+      await manager.refreshList()
+      manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
 
-    // A baseline at or past the row's seq keeps it (nothing phantom to drop).
-    frame({
-      type: 'baseline',
-      value: {
-        queues: {}, jobs: {},
-        projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } },
-      },
-    })
-    expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
+      manager.handleConnected()
+      await manager.refreshList()
+      expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
+      manager.handleControlFrame({
+        type: 'baseline',
+        value: {
+          jobs: {},
+          projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } },
+        },
+      })
+      expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
+    } finally {
+      await manager.dispose()
+    }
   })
 })
 
@@ -793,6 +784,74 @@ describe('remaining branches', () => {
 })
 
 describe('connected generation', () => {
+  it.for(['old-first', 'new-first'] as const)(
+    'ignores a previous generation list response (%s)',
+    async (order, { mock, remote }) => {
+      const oldList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+      const newList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+      let calls = 0
+      remote.session.list.mockImplementation(() => calls++ === 0 ? oldList.promise : newList.promise)
+      const manager = makeManager(mock, remote)
+      const oldResult = ok({ items: [{ ...summary(S1), projections: {
+        asOfSeq: 20, values: { title: 'Unpersisted title' },
+      } }] as never[] })
+      const newResult = ok({ items: [{ ...summary(S1), projections: {
+        asOfSeq: 1, values: { title: 'Durable title' },
+      } }] as never[] })
+      const oldPull = manager.refreshList()
+      let newPull: Promise<void> | undefined
+      try {
+        manager.handleConnected()
+        newPull = manager.refreshList()
+        expect(remote.session.list.mock.calls).toHaveLength(2)
+        if (order === 'old-first') {
+          oldList.resolve(oldResult)
+          await oldPull
+          expect(manager.getListSnapshot().state).toBe('loading')
+          expect(manager.refreshList()).toBe(newPull)
+        }
+        newList.resolve(newResult)
+        await newPull
+        oldList.resolve(oldResult)
+        await oldPull
+
+        expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', error: null })
+        expect(manager.getListSnapshot().items[0]?.title).toBe('Durable title')
+      } finally {
+        oldList.resolve(oldResult)
+        newList.resolve(newResult)
+        await Promise.all([oldPull, newPull])
+        await manager.dispose()
+      }
+    },
+  )
+
+  it('ignores a previous generation request failure while the new list is loading', async ({ mock, remote }) => {
+    const oldList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+    const newList = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+    let calls = 0
+    remote.session.list.mockImplementation(() => calls++ === 0 ? oldList.promise : newList.promise)
+    const manager = makeManager(mock, remote)
+    const oldPull = manager.refreshList()
+    let newPull: Promise<void> | undefined
+    try {
+      manager.handleConnected()
+      newPull = manager.refreshList()
+      oldList.reject(new RemoteError('gateway/internal', 'old Host disconnected', {}))
+      await oldPull
+      expect(manager.getListSnapshot()).toMatchObject({ state: 'loading', error: null })
+      expect(manager.refreshList()).toBe(newPull)
+      newList.resolve(ok({ items: [summary(S1)] as never[] }))
+      await newPull
+      expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', error: null })
+    } finally {
+      oldList.resolve(ok({ items: [] }))
+      newList.resolve(ok({ items: [] }))
+      await Promise.all([oldPull, newPull])
+      await manager.dispose()
+    }
+  })
+
   it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => {
     mock.stream(FOLLOW, followScript(ok({
       records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[],
@@ -997,7 +1056,7 @@ describe('background-job mirror', () => {
     manager.handleControlFrame(tasksFrame(S1, [view()]))
     manager.handleControlFrame({
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     })
     expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
   })

+ 27 - 12
packages/api/session-controller/tests/projection-store.client.spec.ts

@@ -3,7 +3,7 @@
  * docs/subsystems/session-projection.md): the single
  * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
  * newer push frame; a replayed frame cannot regress), capability absence as
- * undefined, generation truncation, and the Session/manager wiring (tail-page
+ * undefined, generation invalidation, and the Session/manager wiring (tail-page
  * seeding, control-stream projection routing pre- and post-instantiation, the
  * list rows' title projection).
  */
@@ -75,13 +75,30 @@ describe('Session projection value semantics', () => {
     expect(store.get('test/marks')).toBeUndefined()
   })
 
-  it('truncate drops rows past the durable baseline and keeps the rest', () => {
+  it('clears all generation watermarks without replacing subscribed faces', async () => {
     const store = new ProjectionValueStore()
-    store.apply('test/marks', { marks: ['durable'] }, SessionSeq(5))
-    store.apply('other', 'phantom', SessionSeq(50))
-    store.truncate(SessionSeq(10))
-    expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
-    expect(store.get('other')).toBeUndefined()
+    const face = store.faceOf('test/marks')
+    const observed: unknown[] = []
+    const unsubscribe = face.subscribe(() => { observed.push(face.getSnapshot()) })
+    try {
+      store.apply('test/marks', { marks: ['lost-tail'] }, SessionSeq(20))
+      store.apply('empty-session', 'old generation', -1)
+      const previous = store.values()
+      await Promise.resolve()
+      store.clear()
+      await Promise.resolve()
+
+      expect(face.getSnapshot()).toBeUndefined()
+      expect(store.get('empty-session')).toBeUndefined()
+      expect(store.faceOf('test/marks')).toBe(face)
+      expect(store.values()).toEqual({})
+      expect(store.values()).not.toBe(previous)
+      store.seed({ asOfSeq: SessionSeq(1), values: { 'test/marks': { marks: ['durable'] } } })
+      await Promise.resolve()
+      expect(observed).toEqual([{ marks: ['lost-tail'] }, undefined, { marks: ['durable'] }])
+    } finally {
+      unsubscribe()
+    }
   })
 
   it('notifies the key face on change (batched) and not on dropped applications', async () => {
@@ -167,7 +184,7 @@ describe('manager frame routing', () => {
     expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
   })
 
-  it('projects the title key into list rows and truncates phantom rows on the control baseline', async ({ mock, remote }) => {
+  it('preserves a newer title when the control baseline omits it', async ({ mock, remote }) => {
     const manager = makeManager(mock, remote)
     remote.session.list.mockResolvedValue(ok({
       items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
@@ -178,17 +195,15 @@ describe('manager frame routing', () => {
     })
     await Promise.resolve()
     expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
-    // The durable baseline says the host only knows up to seq 2: the row rode
-    // lost state and must drop (the un-flushed title precedent).
     manager.handleControlFrame({
       type: 'baseline',
       value: {
-        queues: {}, jobs: {},
+        jobs: {},
         projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
       },
     })
     await Promise.resolve()
-    expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
+    expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
   })
 
   it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => {

+ 0 - 289
packages/api/session-controller/tests/queue-store.client.spec.ts

@@ -1,289 +0,0 @@
-/**
- * Queue snapshot semantics: authoritative replacement after every host-side
- * change, reconnect re-baselining, pre-instantiation buffering, editable-text
- * projection, and snapshot reference stability.
- */
-import { describe, expect, vi } from 'vitest'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
-import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
-import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
-import { SessionManager } from '../src/client/sessions/manager.ts'
-import { sessionBench } from './remote/bench.client.ts'
-import { pushEvent, sessionWorld } from './remote/session.client.ts'
-
-/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
-const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
-const it = createClientTest({ roster: API_ROSTER })
-const SID = 'fk-q1' as SessionId
-/** The first client boot pays the cold module transform of the api cone. */
-const COLD_BOOT_TIMEOUT_MS = 60_000
-const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
-const rid = (id: string): RpcId => id as RpcId
-const iid = (id: string): MessageId => id as MessageId
-
-interface QueueFixture {
-  id: string
-  body: string
-  content?: ContentBlock[]
-  placement?: 'queued' | 'steering'
-  message?: UserMessage
-}
-
-/** Build one authoritative queue snapshot. */
-function queueFrame(items: QueueFixture[]): Extract<SessionControlFrame, { type: 'queue' }> {
-  return {
-    type: 'queue',
-    sessionId: SID,
-    items: items.map(item => ({
-      id: iid(item.id),
-      placement: item.placement ?? 'queued',
-      message: (item.message ?? createUserMessage({
-        content: item.content ?? text(item.body),
-        source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
-      })) as never,
-    })),
-  }
-}
-
-describe('Session queue snapshot intake', () => {
-  it('projects stable ids, flat previews, and complete text', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([
-      { id: 'q-1', body: '第一条  排队\n消息' },
-    ]))
-    const queue = session.getSnapshot().queue
-    expect(typeof queue[0]?.messageId).toBe('string')
-    expect(queue).toMatchObject([
-      {
-        id: 'q-1', placement: 'queued',
-        content: [{ type: 'text', text: '第一条  排队\n消息' }],
-        preview: '第一条 排队 消息', text: '第一条  排队\n消息',
-      },
-    ])
-  }, COLD_BOOT_TIMEOUT_MS)
-
-  it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([{
-      id: 'q-image',
-      body: '',
-      content: [
-        { type: 'text', text: 'hi' },
-        { type: 'image', data: 'x' } as never,
-        { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never,
-      ],
-    }]))
-    const queue = session.getSnapshot().queue
-    expect(typeof queue[0]?.messageId).toBe('string')
-    expect(queue).toMatchObject([
-      {
-        id: 'q-image', placement: 'queued',
-        content: [
-          { type: 'text', text: 'hi' },
-          { type: 'image', data: 'x' },
-          { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } },
-        ],
-        // Attachment blocks render from `content`, so the preview carries
-        // only text; other foreign blocks keep their marker.
-        preview: 'hi', text: null,
-      },
-    ])
-  })
-
-  it('caps previews at 200 code points and preserves the full editable text', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    const body = '长'.repeat(201)
-    session.handleControlFrame(queueFrame([{ id: 'q-cap', body }]))
-    const row = session.getSnapshot().queue[0]
-    expect(Array.from(row?.preview ?? '')).toHaveLength(201)
-    expect(row?.preview.endsWith('…')).toBe(true)
-    expect(row?.text).toBe(body)
-  })
-
-  it('replaces content, order, and membership from each authoritative frame', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([
-      { id: 'q-1', body: 'one' },
-      { id: 'q-2', body: 'two' },
-    ]))
-    session.handleControlFrame(queueFrame([
-      { id: 'q-2', body: 'two edited' },
-    ]))
-    const queue = session.getSnapshot().queue
-    expect(typeof queue[0]?.messageId).toBe('string')
-    expect(queue).toMatchObject([
-      {
-        id: 'q-2', placement: 'queued',
-        content: [{ type: 'text', text: 'two edited' }],
-        preview: 'two edited', text: 'two edited',
-      },
-    ])
-    session.handleControlFrame(queueFrame([]))
-    expect(session.getSnapshot().queue).toEqual([])
-  })
-
-  it('keeps the queue array reference stable across unrelated snapshot swaps', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }]))
-    const before = session.getSnapshot().queue
-    session.handleAgentError('unrelated')
-    expect(session.getSnapshot().queue).toBe(before)
-  })
-
-  it('retains steering placement and complete content in the same authoritative snapshot', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([
-      { id: 'q-next', body: 'later' },
-      { id: 's-now', body: 'interrupt now', placement: 'steering' },
-    ]))
-
-    expect(session.getSnapshot().queue.map(item => ({
-      id: item.id, placement: item.placement, content: item.content,
-    }))).toEqual([
-      { id: 'q-next', placement: 'queued', content: text('later') },
-      { id: 's-now', placement: 'steering', content: text('interrupt now') },
-    ])
-  })
-
-  it('hands off exactly one current occurrence when live steering becomes durable', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    await session.open()
-    const message = createUserMessage({
-      content: text('same message'),
-      source: { kind: 'user' },
-    })
-    session.handleControlFrame(queueFrame([
-      { id: 's-first', body: '', placement: 'steering', message },
-      { id: 's-second', body: '', placement: 'steering', message },
-    ]))
-    const durable = {
-      seq: SessionSeq(0),
-      time: 1_700_000_000_000,
-      type: 'user/message',
-      surfaceOp: 'append',
-      data: message,
-    } satisfies SessionEvent
-
-    await pushEvent(mock, durable)
-    await vi.waitFor(() => {
-      expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
-    })
-
-    session.handleControlFrame(queueFrame([
-      { id: 's-later', body: '', placement: 'steering', message },
-    ]))
-    await pushEvent(mock, durable)
-    await vi.waitFor(() => {
-      expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
-    })
-  })
-
-  it('hands off live steering when the agent claims it as a user message', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    await session.open()
-    const message = createUserMessage({
-      content: text('claimed steering'),
-      source: { kind: 'user' },
-    })
-    session.handleControlFrame(queueFrame([
-      { id: 's-claimed', body: '', placement: 'steering', message },
-    ]))
-
-    await pushEvent(mock, {
-      seq: 0,
-      time: 1_700_000_000_000,
-      type: 'user/message',
-      surfaceOp: 'append',
-      data: message,
-    } as never)
-
-    await vi.waitFor(() => {
-      expect(session.getSnapshot().queue).toEqual([])
-    })
-  })
-})
-
-describe('queue operation transport', () => {
-  it('addresses the session.updateQueue RPC without optimistic local mutation', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
-    const before = session.getSnapshot().queue
-
-    await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
-      .resolves.toEqual({ ok: true, value: { accepted: true } })
-    await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
-      .resolves.toEqual({ ok: true, value: { accepted: true } })
-    expect(mock.log.requests('session/updateQueue')).toEqual([
-      {
-        sessionId: SID,
-        itemId: 'q-op',
-        action: { kind: 'edit', content: text('next') },
-      },
-      {
-        sessionId: SID,
-        itemId: 'q-op',
-        action: { kind: 'steer' },
-      },
-    ])
-    expect(session.getSnapshot().queue).toBe(before)
-  })
-})
-
-describe('queue reconnect semantics', () => {
-  it('a control baseline clears stale state before a fresh update lands', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }]))
-    session.replaceControl([])
-    expect(session.getSnapshot().queue).toEqual([])
-    session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }]))
-    expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
-  })
-
-  it('resync does not clear a baseline that raced ahead of the host connection signal', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    await session.open()
-    session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }]))
-    await session.resync()
-    expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
-  })
-
-  it('running-status changes never guess at queue retirement', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }]))
-    session.handleRunning(true)
-    session.handleRunning(false)
-    expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
-  })
-})
-
-describe('manager buffering of queue snapshots', () => {
-  it('replays only the latest snapshot for an uninstantiated session', async ({ mock, start }) => {
-    mock.load(sessionWorld)
-    const { ctx: { remote } } = await start()
-    const manager = new SessionManager(remote)
-    manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }]))
-    manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }]))
-    expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
-  })
-
-  it('a control baseline replaces the prior queue', async ({ mock, start }) => {
-    mock.load(sessionWorld)
-    const { ctx: { remote } } = await start()
-    const manager = new SessionManager(remote)
-    manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }]))
-    const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items
-    manager.handleControlFrame({
-      type: 'baseline',
-      value: {
-        queues: { [SID]: nextQueue },
-        jobs: {},
-        projections: {},
-      },
-    })
-    const snapshot = manager.get(SID).getSnapshot()
-    expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
-  })
-})

+ 5 - 48
packages/api/session-controller/tests/remote/session.client.ts

@@ -3,18 +3,16 @@
  * `session/*` and `subagents/*` endpoint a `Session` or its manager calls, builders for
  * the two history-shaped answers, the `session/follow` opening snapshot and
  * the `session/page` page, both derived from event lists the way the Host
- * derives them from its log, and builders for the `session/control` queue
- * frame and the attachment references the Host's log carries.
+ * derives them from its log, and builders for the attachment references
+ * the Host's log carries.
  */
 import { AttachmentId, type FileAttachmentRef, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import { ok, type RemoteMock, type RemoteTable, type StreamScript, type UnaryRuleFn } from '@deepseek-ai/dsh-remote-mock'
-import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type {
-  SessionAssistantStreamBaseline, SessionControlFrame, SessionFollowFrame, SessionFollowRequest,
-  SessionPage, SessionPageRequest, SessionQueuedItem, SessionRequestId,
+  SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest,
+  SessionPage, SessionPageRequest,
 } from '../../src/types.ts'
 import { entries, historyValue } from '../event-script.client.ts'
 import { followSnapshot, pageThrough } from './history.client.ts'
@@ -135,47 +133,6 @@ export function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
   return { attachmentId: AttachmentId(id), name, bytes: 3 }
 }
 
-/** One pending inbox occurrence of a queue frame; the message id defaults to the item id. */
-export interface QueueItemFixture {
-  readonly id: string
-  readonly placement?: SessionQueuedItem['placement']
-  /** Prompt identity the Host copied from the queued message's user source. */
-  readonly rpcId?: SessionRequestId
-  /** Queued message content; ignored when `message` is given. */
-  readonly content?: readonly ContentBlock[]
-  /** The queued message itself, for a test that also lands it durably (the queue mirror correlates by `id`). */
-  readonly message?: { readonly id: MessageId; readonly content: readonly ContentBlock[] }
-}
-
-/**
- * One pending inbox occurrence as the Host lists it.
- * @param fixture - item fields.
- * @returns the item.
- */
-export function queueItem(fixture: QueueItemFixture): SessionQueuedItem {
-  const message = fixture.message ?? { id: fixture.id as MessageId, content: fixture.content ?? [] }
-  return {
-    id: fixture.id as MessageId,
-    placement: fixture.placement ?? 'queued',
-    ...(fixture.rpcId === undefined ? {} : { rpcId: fixture.rpcId }),
-    // The Host serializes queued content to JSON; the blocks cross unchanged.
-    message: { id: message.id, content: message.content as unknown as SessionQueuedItem['message']['content'] },
-  }
-}
-
-/**
- * One authoritative queue frame of the `session/control` stream.
- * @param sessionId - the addressed Session.
- * @param items - the complete pending queue.
- * @returns the frame.
- */
-export function queueFrame(
-  sessionId: SessionId,
-  items: readonly QueueItemFixture[],
-): Extract<SessionControlFrame, { type: 'queue' }> {
-  return { type: 'queue', sessionId, items: items.map(queueItem) }
-}
-
 /** Default answers: every command accepted, empty history and subagent catalog, one attachment of one zero byte. */
 export const sessionWorld: RemoteTable = {
   unary: {

+ 109 - 1
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -14,7 +14,7 @@ import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
-import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createInboxStub, mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
 import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import AttachmentStore from '@deepseek-ai/dsh-attachment'
@@ -22,6 +22,7 @@ import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
 import {
   SessionPersistenceRevision,
   type SessionPersistenceSnapshot,
+  type SessionHandle, SessionAccess,
 } from '@deepseek-ai/dsh-session-persistence'
 import {
   createSessionTestRemote,
@@ -271,6 +272,79 @@ describe('cold history recovery view', () => {
 })
 
 describe('Remote Agent and Session lookup policy', () => {
+  it('resumes a cold session before mutating a restored queue row', async () => {
+    const ctx = new Context()
+    await mountAgentLoopTestDependencies(ctx)
+    await mountAgentLoopTestHarness(ctx)
+    const sessionId = sid('session-cold-queue-mutation')
+    const meta = header(sessionId, 1000)
+    const message = createUserMessage({
+      content: [{ type: 'text', text: 'survives restart' }],
+      source: { kind: 'user' },
+    })
+    const events = [{
+      type: 'agent/inbox/spliced',
+      seq: 0,
+      time: 1001,
+      data: { target: 'next-turn', start: 0, inserted: [message] },
+    }] as SessionEvent[]
+    providePersistence(ctx, {
+      list: () => Promise.resolve([meta]),
+      inspect: () => Promise.resolve({ meta, events }),
+      open: (_id: SessionId, access: SessionAccess): Promise<SessionHandle> => Promise.resolve({
+        id: sessionId,
+        header: meta,
+        inheritedEventCount: SessionLogOffset(0),
+        access,
+        read: () => Promise.resolve({ eventState: 'detached', events: structuredClone(events) }),
+        append: (appended) => { events.push(...appended); return Promise.resolve() },
+        flush: () => Promise.resolve(),
+        close: () => Promise.resolve(),
+        [Symbol.asyncDispose]: () => Promise.resolve(),
+      }),
+    })
+    const resume = vi.spyOn(ctx.agents, 'resume')
+    const remote = createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/tmp',
+    })
+
+    const response = await remote.updateQueue(request({
+      sessionId,
+      itemId: message.id,
+      action: { kind: 'remove' },
+    }))
+
+    expect(response).toEqual({ ok: true, value: { accepted: true } })
+    expect(resume).toHaveBeenCalledOnce()
+    const resumedAgent = ctx.agents.get(sessionId)
+    expect(resumedAgent?.inbox.nextTurn).toEqual([])
+    expect(resumedAgent?.session.snapshotEvents().at(-1)).toMatchObject({
+      type: 'agent/inbox/spliced',
+      data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
+    })
+    await ctx.fiber.dispose()
+  })
+
+  it('keeps queue-item-not-found for a cold session when no persistence backend is composed', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    const remote = createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/tmp',
+    })
+
+    const response = await remote.updateQueue(request({
+      sessionId: sid('session-no-persistence'),
+      itemId: MessageId('queued-item'),
+      action: { kind: 'remove' },
+    }))
+
+    expect(response.ok).toBe(false)
+    if (!response.ok) expect(response.error.code).toBe('session/queue-item-not-found')
+  })
+
   it('deduplicates a cold resume across Agent and Session parameters', async () => {
     const ctx = new Context()
     await ctx.plugin(TypertRegistry)
@@ -354,6 +428,40 @@ describe('Remote Agent and Session lookup policy', () => {
     expect(resume).not.toHaveBeenCalled()
     expect(inspect).toHaveBeenCalledOnce()
   })
+
+  it('reapplies the subagent ownership fence after a successful resume publishes the Agent', async () => {
+    const ctx = new Context()
+    await ctx.plugin(TypertRegistry)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    const sessionId = sid('session-remote-resumed-child')
+    const meta = header(sessionId, 1000)
+    providePersistence(ctx, {
+      list: () => Promise.resolve([meta]),
+      inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
+      locate: () => undefined,
+    })
+    vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
+      const session = ctx.sessions.create(sessionId, {
+        meta: { cwd: '/proj', origin: 'subagent' },
+      })
+      const published = { id: session.id, session, status: 'idle', ctx } as Agent
+      await ctx.agents.register(published)
+      return { agent: published, dispose: () => Promise.resolve() }
+    })
+    const defaultLookup = ctx.typert.lookups.get('agent')
+    createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/tmp',
+    })
+    await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultLookup) })
+    const lookup = ctx.typert.lookups.get('agent')
+    if (lookup === undefined) throw new Error('Agent lookup provider was not mounted')
+
+    const resolution = lookup.resolve(sessionId)
+
+    await expect(resolution).rejects.toMatchObject({ code: 'session/agent-busy' })
+  })
 })
 
 describe('subagent ownership fence', () => {

+ 32 - 9
packages/api/session-controller/tests/session-pending-submissions.client.spec.ts

@@ -15,7 +15,7 @@ import type { PendingSubmissionRetirement } from '../src/client/contract/session
 import type { SessionRequestId } from '../src/types.ts'
 import { sessionBench } from './remote/bench.client.ts'
 import {
-  FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent, queueFrame,
+  FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent,
 } from './remote/session.client.ts'
 
 /** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
@@ -54,14 +54,17 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly At
   } as unknown as SessionEvent
 }
 
-/** The Host's queue holding one occurrence of the prompt `rpcId`. */
-function queuedFrame(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) {
-  return queueFrame(SID, [{ id: 'm-queued', rpcId, content: refs.map(attachmentBlock) }])
+function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) {
+  return createUserMessage({
+    source: { kind: 'user', rpcId },
+    content: refs.map(attachmentBlock),
+  })
 }
 
 /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */
-function settleFrames(): Promise<void> {
-  return new Promise(resolve => setTimeout(resolve, 0))
+async function settleFrames(): Promise<void> {
+  await Promise.resolve()
+  await new Promise(resolve => setTimeout(resolve, 0))
 }
 
 describe('beginSubmission', () => {
@@ -172,6 +175,24 @@ describe('observed retirement', () => {
     expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
   })
 
+  it('retires an accepted echo when a claim clears the projection before its notification', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    await session.open()
+    const onRetire = vi.fn()
+    const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire })
+    const refs = [imageRef('claimed-image')]
+    const message = queuedItem(handle.requestId, refs)
+    session.projections.apply('inbox', { 'next-turn': [], 'next-step': [message] }, SessionSeq(0))
+    session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(1))
+    await pushEvent(mock, {
+      type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
+      data: { target: 'next-step', start: 0, inserted: [message] },
+    })
+    await settleFrames()
+    expect(session.getSnapshot().pendingSubmissions).toEqual([])
+    expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs })
+  })
+
   it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => {
     const session = await sessionBench(mock, start, SID)
     const retirements: PendingSubmissionRetirement[] = []
@@ -183,12 +204,14 @@ describe('observed retirement', () => {
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-q')]
-    session.handleControlFrame(queuedFrame(handle.requestId, refs))
+    session.projections.apply('inbox', { 'next-turn': [queuedItem(handle.requestId, refs)], 'next-step': [] }, SessionSeq(1))
     await settleFrames()
     expect(session.getSnapshot().pendingSubmissions).toEqual([])
     expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
     // The queue projection keeps the correlation id for render-time dedupe.
-    expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
+    expect(session.projections.get('inbox')).toMatchObject({
+      'next-turn': [{ source: { rpcId: handle.requestId } }],
+    })
   })
 
   it('retires a mixed echo with durable references in original selection order', async ({ mock, start }) => {
@@ -247,7 +270,7 @@ describe('observed retirement', () => {
       attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
-    session.handleControlFrame(queuedFrame(handle.requestId))
+    session.projections.apply('inbox', { 'next-turn': [queuedItem(handle.requestId, [])], 'next-step': [] }, SessionSeq(1))
     await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId))
     await settleFrames()
     expect(retirements).toEqual([{ reason: 'observed', attachments: [] }])

+ 4 - 1
packages/api/session-controller/tests/session-projections.host.spec.ts

@@ -236,7 +236,10 @@ describe('session.history projections block', () => {
   })
 
   it('reconstructs a cold persisted queue without publishing or resuming an Agent', async () => {
-    const { ctx } = await harness(true)
+    const ctx = new Context()
+    ownedContexts.add(ctx)
+    await mountAgentLoopTestDependencies(ctx)
+    await mountAgentLoopTestHarness(ctx)
     const coldId = SessionId('cold-persisted-queue')
     const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 1, cwd: '/tmp', isSeeded: false }
     const message = createUserMessage({

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

@@ -444,7 +444,7 @@ describe('scope tree', () => {
 
     b.svc.handleControlFrame({
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     })
     await Promise.resolve()
     expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped)

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

@@ -658,10 +658,10 @@ describe('Session Client stream adapters', () => {
   it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {
     const baseline: SessionControlFrame = {
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     }
     const update: SessionControlFrame = {
-      type: 'queue', sessionId: 'session-1' as never, items: [],
+      type: 'jobs', sessionId: 'session-1' as never, jobs: [],
     }
     const remote = new ScriptedSessionRemote([], [], [baseline, update])
     const accept = vi.fn<(frame: SessionControlFrame) => void>()
@@ -693,7 +693,7 @@ describe('Session Client stream adapters', () => {
 
     const baseline: SessionControlFrame = {
       type: 'baseline',
-      value: { queues: {}, jobs: {}, projections: {} },
+      value: { jobs: {}, projections: {} },
     }
     const carrierFailed = vi.fn()
     const failed = vi.fn()

+ 6 - 5
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -6,6 +6,7 @@ import type {
   ConversationTimelineSnapshot, RenderMessageImages,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
 import type { ChatSnapshot } from '../contract/snapshot.ts'
@@ -139,7 +140,7 @@ function openFailureMessage(error: unknown, fallback: string): string {
 function observedRpcIds(
   order: readonly string[],
   nodes: ChatSnapshot['nodes'],
-  queue: readonly { readonly rpcId?: string }[],
+  inbox: InboxState | undefined,
 ): ReadonlySet<string> {
   const observed = new Set<string>()
   for (const key of order) {
@@ -150,8 +151,8 @@ function observedRpcIds(
       | undefined
     if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId)
   }
-  for (const item of queue) {
-    if (item.rpcId !== undefined) observed.add(item.rpcId)
+  for (const { source } of [...inbox?.['next-turn'] ?? [], ...inbox?.['next-step'] ?? []]) {
+    if (source.kind === 'user' && 'rpcId' in source) observed.add(source.rpcId)
   }
   return observed
 }
@@ -233,7 +234,7 @@ export function ChatView({
     [turnNavigationItems, turnOutline],
   )
   const timeline = useChat(s => s.timeline)
-  const inbox = useSession(s => s.queue)
+  const inbox = useProjection('inbox') as unknown as InboxState | undefined
   // Workspace root off the session list row: path summaries display relative to it.
   const cwd = useSessions(s => s.byId[sessionId]?.cwd)
   const running = useSession(s => s.running)
@@ -281,7 +282,7 @@ export function ChatView({
   }, [])
 
   const pendingSteering = useMemo(
-    () => inbox.filter(item => item.placement === 'steering'),
+    () => inbox?.['next-step'].filter(message => message.source.kind === 'user') ?? [],
     [inbox],
   )
   const pendingSubmissions = useSession(s => s.pendingSubmissions)

+ 37 - 37
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -1,5 +1,6 @@
 // @vitest-environment jsdom
 
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@@ -61,10 +62,13 @@ beforeEach(() => {
 const SID = 's1' as SessionId
 type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
 
-function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
+interface TestSessionSnapshot extends SessionSnapshot {
+  readonly testInbox?: InboxState
+}
+
+function sessionSnapshot(overrides: Partial<TestSessionSnapshot> = {}): TestSessionSnapshot {
   return {
     sessionId: SID,
-    queue: [],
     pendingSubmissions: [],
     running: false,
     removed: false,
@@ -83,11 +87,11 @@ function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnaps
 }
 
 /** Scripted Session source: set() swaps the top-level object like the real Controller binding. */
-function makeSessionSource(init: Partial<SessionSnapshot> = {}) {
+function makeSessionSource(init: Partial<TestSessionSnapshot> = {}) {
   let snap = sessionSnapshot(init)
   const subs = new Set<() => void>()
   return {
-    set: (next: Partial<SessionSnapshot>) => {
+    set: (next: Partial<TestSessionSnapshot>) => {
       snap = { ...snap, ...next }
       for (const fn of [...subs]) fn()
     },
@@ -104,7 +108,7 @@ function makeSessionSource(init: Partial<SessionSnapshot> = {}) {
 type ChatSlice = Partial<LegacyConversationSlice> & {
   readonly turnUsages?: NonNullable<Parameters<typeof chatSnapshotFixture>[0]>['turnUsages']
 }
-type HarnessUpdate = ChatSlice & Partial<SessionSnapshot> & { readonly chat?: ChatSnapshot }
+type HarnessUpdate = ChatSlice & Partial<TestSessionSnapshot> & { readonly chat?: ChatSnapshot }
 
 /** Scripted Chat target source, independent from Session lifecycle state. */
 function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
@@ -227,7 +231,7 @@ function bindKeyedSnapshotSelector<Value>(
 
 function makeHarness(
   init: HarnessUpdate = {},
-  sessionOverrides: Partial<SessionSnapshot> = {},
+  sessionOverrides: Partial<TestSessionSnapshot> = {},
   chatSnapshot?: ChatSnapshot,
 ) {
   const {
@@ -243,6 +247,7 @@ function makeHarness(
     ...(turnUsages === undefined ? {} : { turnUsages }),
   }
   const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
+  const useTestSession = bindSnapshotSelector(session.source)
   const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
   const useChatNode = bindKeyedSnapshotSelector(
     key => chatSource.source.getSnapshot().nodes.source(key),
@@ -380,7 +385,10 @@ function makeHarness(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),
     useWorkspaces: emptyWorkspaces(),
-    useProjection: () => outlineValue,
+    useProjection: (key: string) => {
+      const inbox = useTestSession(snapshot => snapshot.testInbox)
+      return key === 'inbox' ? inbox : outlineValue
+    },
     useInput: (() => { throw new Error('unused') }),
     inputActions: {
       setDraft: () => {},
@@ -962,23 +970,23 @@ describe('ChatView', () => {
     })
     const pending = {
       id: 'steer-occurrence' as never,
-      messageId: 'steer-message' as never,
-      placement: 'steering' as const,
+      role: 'user' as const,
+      source: { kind: 'user' as const },
       content: [{ type: 'text' as const, text: 'interrupt now' }],
       preview: 'interrupt now',
       text: 'interrupt now',
     }
     const queued = {
       id: 'queued-occurrence' as never,
-      messageId: 'queued-message' as never,
-      placement: 'queued' as const,
+      role: 'user' as const,
+      source: { kind: 'user' as const },
       content: [{ type: 'text' as const, text: 'later' }],
       preview: 'later',
       text: 'later',
     }
     const h = makeHarness(
       { nodes: [assistant(1, 'working')] },
-      { queue: [queued, pending], running: true },
+      { testInbox: { 'next-turn': [queued], 'next-step': [pending] }, running: true },
     )
     const view = render(<h.ChatView {...h.props} />)
 
@@ -993,12 +1001,12 @@ describe('ChatView', () => {
       & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
 
     act(() => {
-      h.setSession({ queue: [queued] })
+      h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } })
       h.setChat({
         nodes: [
           assistant(1, 'working'),
           {
-            kind: 'steering', messageId: pending.messageId,
+            kind: 'steering', messageId: pending.id,
             seq: 2, time: 2_000,
             content: [{ type: 'text', text: 'interrupt now' }], source: null,
           },
@@ -1030,8 +1038,8 @@ describe('ChatView', () => {
   it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
     const pending = {
       id: 'steer-occurrence-later' as never,
-      messageId: 'shared-steer-message' as never,
-      placement: 'steering' as const,
+      role: 'user' as const,
+      source: { kind: 'user' as const },
       content: [{ type: 'text' as const, text: 'same steering' }],
       preview: 'same steering',
       text: 'same steering',
@@ -1041,7 +1049,7 @@ describe('ChatView', () => {
         kind: 'user', seq: 2, time: 2_000,
         content: pending.content, source: null,
       }],
-    }, { queue: [pending], running: true })
+    }, { testInbox: { 'next-turn': [], 'next-step': [pending] }, running: true })
     const view = render(<h.ChatView {...h.props} />)
 
     expect(view.getAllByText('same steering')).toHaveLength(2)
@@ -1107,15 +1115,12 @@ describe('ChatView', () => {
 
     act(() => {
       h.setSession({
-        queue: [{
+        testInbox: { 'next-turn': [], 'next-step': [{
           id: 'steer-occurrence' as never,
-          messageId: 'steer-message' as never,
-          placement: 'steering',
-          rpcId: 'req-steer' as never,
           content: [{ type: 'text', text: '带图纠偏' }],
-          preview: '带图纠偏',
-          text: '带图纠偏',
-        }],
+
+          role: 'user', source: { kind: 'user', rpcId: 'req-steer' as never },
+        }] },
       })
     })
     expect(view.getAllByText('带图纠偏')).toHaveLength(1)
@@ -1140,15 +1145,12 @@ describe('ChatView', () => {
     expect(view.queryByText('排队中')).toBeNull()
     act(() => {
       h.setSession({
-        queue: [{
+        testInbox: { 'next-step': [], 'next-turn': [{
           id: 'q-occurrence' as never,
-          messageId: 'q-message' as never,
-          placement: 'queued' as const,
-          rpcId: 'req-q' as never,
           content: [{ type: 'text' as const, text: '排队中' }],
-          preview: '排队中',
-          text: '排队中',
-        }],
+
+          role: 'user', source: { kind: 'user', rpcId: 'req-q' as never },
+        }] },
       })
     })
     // The queued occurrence and its local predecessor both belong to the
@@ -2181,14 +2183,12 @@ describe('ChatView', () => {
     expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
     expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
     act(() => {
-      h.setSession({ queue: [{
+      h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
         id: 'steering-occurrence' as never,
-        messageId: 'steering-message' as never,
-        placement: 'steering',
         content: [{ type: 'text', text: 'also' }],
-        preview: 'also',
-        text: 'also',
-      }] })
+
+        role: 'user', source: { kind: 'user' },
+      }] } })
     })
     expect(status.textContent).toMatch(/^深度求索中\.\.\.2分0\d秒$/)
   })

+ 2 - 2
packages/client/ui-conversation/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/client/ui-conversation/README.md
-README.md: 2ccd7dd0f8c8b3403122fdcb4588880f3a9b9d4e
-README.zh.md: 2cee3b6c0f3cd23822b97cb235940b6a67bd9ad9
+README.md: 3c2697487ffe5b79bc8209eb9be953cc70977d79
+README.zh.md: 137357eb133a42debbce3232ca7b0858a26ee8c1

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -52,7 +52,7 @@ The shell reads the persisted View preference before rendering when a Session fi
 
 Active transcripts expose content-width drag handles in their uncovered side gutters. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)).
 
-The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
+The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace.
 
 Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -52,7 +52,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
 
 活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。
 
-常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
+常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。
 
 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 

+ 2 - 1
packages/client/ui-conversation/package.json

@@ -92,7 +92,8 @@
     "@lexical/utils": "^0.49.0",
     "@lexical/text": "^0.49.0",
     "@lexical/plain-text": "^0.49.0",
-    "@lexical/history": "^0.49.0"
+    "@lexical/history": "^0.49.0",
+    "@deepseek-ai/dsh-agent": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 3 - 6
packages/client/ui-conversation/src/client/contract/input.ts

@@ -7,10 +7,10 @@
  * here is the submit plane (phase, claim, attempt) alone.
  */
 import type { Context } from '@deepseek-ai/cordis'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
-import type { QueueRow } from './queue.ts'
 import type { InputSubmitMode } from './composer-submission.ts'
 
 /** Attachment payload passed to a claimed command submission. */
@@ -233,9 +233,6 @@ export interface InputNotice {
   readonly seq: number
 }
 
-/** One independently addressable row projected from the transient queue snapshot. */
-export type QueuedMessage = QueueRow
-
 /** Guard union of the scoped consume-token event, checked by the shell. */
 export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
 
@@ -252,8 +249,8 @@ export interface InputState {
   readonly claim?: { readonly name: string; readonly token: string; readonly hint?: string; readonly attachments?: boolean }
   /** Reference occurrence view of the editor's chips, sorted by offset. */
   readonly occurrences: readonly Occurrence[]
-  /** Read-only transient inbox projection from Session control, including pending steering. */
-  readonly queue: readonly QueuedMessage[]
+  /** Messages still waiting for their own turn. */
+  readonly queue: InboxState['next-turn']
 }
 
 /**

+ 0 - 11
packages/client/ui-conversation/src/client/contract/queue.ts

@@ -1,11 +0,0 @@
-/** Queue contracts derived from the Session Controller face. */
-import type { SessionFace, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
-
-/** One address accepted by the Session Controller's queue mutation verb. */
-export type QueueItemId = Parameters<SessionFace['updateQueue']>[0]
-
-/** One mutation accepted by the Session Controller's queue mutation verb. */
-export type QueueAction = Parameters<SessionFace['updateQueue']>[1]
-
-/** One row projected by the authoritative Session queue snapshot. */
-export type QueueRow = SessionSnapshot['queue'][number]

+ 10 - 6
packages/client/ui-conversation/src/client/input/facade.ts

@@ -9,6 +9,7 @@
  * listeners onto it.
  */
 import type { Context } from '@deepseek-ai/cordis'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import {
   createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
@@ -16,7 +17,7 @@ import type { LexicalEditor } from 'lexical'
 import type {
   CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
-  QueuedMessage, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
+  SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome,
 } from '../contract/input.ts'
 import type {
   ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
@@ -44,8 +45,8 @@ export interface SessionInputDeps {
   inputTriggers?: (() => InputTriggerController | undefined) | undefined
   /** PopupSelect shell face resolver (dismissal on submit lock / escape). */
   popup?: (() => PopupDismissFace | undefined) | undefined
-  /** Queue read face; overlaid onto InputState.queue (absent = empty). */
-  queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
+  /** Agent Inbox projection; its next-turn list is overlaid onto InputState.queue. */
+  inbox?: ObservableSnapshot<InboxState | undefined> | undefined
   /**
    * Steer every still-pending queued message into the running turn, in FIFO
    * order (the empty-draft accelerated-Enter gesture); absent = unsupported.
@@ -88,7 +89,7 @@ function projectionContentChanged(prev: EditorProjection, next: EditorProjection
   })
 }
 
-const EMPTY_QUEUE: readonly QueuedMessage[] = []
+const EMPTY_QUEUE: InboxState['next-turn'] = []
 
 /** No-pipeline lexicon: zero text-ref decorations. */
 const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
@@ -152,6 +153,8 @@ export class SessionInputShell implements SessionInput {
     readonly attachmentIds: readonly DraftAttachmentId[]
   }>()
 
+  private readonly unsubscribeInbox: (() => void) | undefined
+
   constructor(private readonly deps: SessionInputDeps) {
     this.draftEditor = new DraftEditorRuntime({
       onUpdate: () => { this.onEditorUpdate() },
@@ -163,7 +166,7 @@ export class SessionInputShell implements SessionInput {
     })
     this.unregister = this.draftEditor.register()
     this.state = createSnapshotStore<InputState>(this.compose())
-    deps.queue?.subscribe(() => { this.publish() })
+    this.unsubscribeInbox = deps.inbox?.subscribe(() => { this.publish() })
   }
 
   // ---- editor plumbing ----
@@ -468,6 +471,7 @@ export class SessionInputShell implements SessionInput {
     }
     this.disposed = true
     this.dispatchRun(({ type: 'release' }))
+    this.unsubscribeInbox?.()
     this.unregister()
     this.detachedDrafts.clear()
     this.failedDetached.clear()
@@ -790,7 +794,7 @@ export class SessionInputShell implements SessionInput {
       phase: core.phase,
       ...(core.claim !== undefined ? { claim: core.claim } : {}),
       occurrences: this.projection.occurrences,
-      queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE,
+      queue: this.deps.inbox?.getSnapshot()?.['next-turn'] ?? EMPTY_QUEUE,
     }
   }
 

+ 5 - 3
packages/client/ui-conversation/src/client/input/hub.ts

@@ -13,7 +13,8 @@ import type {
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
-import { queueReadFaceOf } from './queue-store.ts'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type {
   DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
   SessionInputResolver, SessionInput, SubmitOutcome,
@@ -88,7 +89,7 @@ export class InputHub implements SessionInputResolver {
       actx,
       inputTriggers: () => this.controller(actx),
       popup: () => this.popup(actx),
-      queue: queueReadFaceOf(session),
+      inbox: session.projections.faceOf('inbox') as ObservableSnapshot<InboxState | undefined>,
       defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal),
       steerQueue: () => { void this.steerQueue(session, shell) },
       commandAttachments: {
@@ -217,7 +218,8 @@ export class InputHub implements SessionInputResolver {
    * @param shell - the resident shell (notice outlet).
    */
   private async steerQueue(session: SessionFace, shell: SessionInputShell): Promise<void> {
-    const queued = session.getSnapshot().queue.filter(item => item.placement === 'queued')
+    const inbox = session.projections.faceOf('inbox').getSnapshot() as InboxState | undefined
+    const queued = inbox?.['next-turn'] ?? []
     if (queued.length === 0) return
     for (const item of queued) {
       const result = await session.updateQueue(item.id, { kind: 'steer' })

+ 0 - 25
packages/client/ui-conversation/src/client/input/queue-store.ts

@@ -1,25 +0,0 @@
-/**
- * Queue read face for the InputState.queue projection (frozen contract in
- * ../contract/input.ts): a uSES-compatible observable over one session's
- * transient inbox rows. The Session snapshot already keeps the queue array
- * reference-stable across unrelated snapshot swaps, so this is a pure
- * projection — no second store, no copy.
- */
-import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
-import type { QueuedMessage } from '../contract/input.ts'
-
-/**
- * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot).
- * The wiring layer overlays this onto InputState.queue; the runtime
- * QueuedMessage and the input-contract QueuedMessage are structurally
- * identical.
- * @param session - the resident session face.
- * @returns the queue read face (snapshot reference stable while the queue is unchanged).
- */
-export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
-  return {
-    getSnapshot: () => session.getSnapshot().queue,
-    subscribe: fn => session.subscribe(fn),
-  }
-}

+ 38 - 16
packages/client/ui-conversation/src/client/queue/QueueDock.tsx

@@ -8,26 +8,45 @@ import {
   FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14,
   IconTrashOutline16, projectUserText, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import { NS } from '../locales.ts'
 import css from './QueueDock.module.css'
 
+const EMPTY_QUEUE = [] as const
+const QUEUE_PREVIEW_CHARS = 200
+
+function previewOf(content: InboxState['next-turn'][number]['content']): string {
+  const flat = content
+    .filter(block => block.type !== 'image' && block.type !== 'file')
+    .map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
+    .join(' ').replace(/\s+/g, ' ').trim()
+  const chars = Array.from(flat)
+  return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat
+}
+
+function textOf(content: InboxState['next-turn'][number]['content']): string | null {
+  if (!content.every(block => block.type === 'text')) return null
+  return content.map(block => block.text).join('')
+}
+
 /** Queue operations injected by the session-scoped registration. */
 export interface QueueDockInjected {
-  updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
+  updateQueue: (itemId: MessageId, action: QueueAction) => Promise<void>
   notify: (level: 'info' | 'error', text: string) => void
   /** Resolve one durable queued image into a session-scoped browser URL. */
   loadImage: (attachment: ImageAttachmentRef) => Promise<string>
 }
 
 /**
- * Durable references carried by one queued row. Queue frames are wire data
+ * Durable references carried by one queued row. Inbox projections are wire data
  * despite their typed face, so an image block without a reference is skipped
  * rather than trusted.
  * @param content - the row's wire content blocks.
  * @returns the row's durable image references in block order.
  */
-function queueAttachments(content: QueueRow['content']): Array<
+function queueAttachments(content: InboxState['next-turn'][number]['content']): Array<
   | { readonly type: 'image'; readonly attachment: ImageAttachmentRef }
   | { readonly type: 'file'; readonly attachment: FileAttachmentRef }
 > {
@@ -87,12 +106,14 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
  * collapsible count header; an empty queue renders nothing. Local submissions
  * show sending status and disabled actions until their Host queue rows arrive.
  */
-export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) {
-  const inbox = useSession(s => s.queue)
-  const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
+export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, t }: QueueDockProps) {
+  const inbox = useProjection('inbox') as unknown as InboxState | undefined
+  const queue = inbox?.['next-turn'] ?? EMPTY_QUEUE
   const pendingSubmissions = useSession(s => s.pendingSubmissions)
   const pendingQueue = useMemo(() => {
-    const admitted = new Set(queue.flatMap(row => row.rpcId === undefined ? [] : [row.rpcId]))
+    const admitted = new Set(queue.flatMap(({ source }) => (
+      source.kind === 'user' && 'rpcId' in source ? [source.rpcId] : []
+    )))
     return pendingSubmissions.filter(submission => (
       submission.placement === 'queued' && !admitted.has(submission.requestId)
     ))
@@ -100,8 +121,8 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
   const rowCount = queue.length + pendingQueue.length
   const running = useSession(s => s.running)
   const queueMutable = useSession(s => s.subagent === null || s.subagent.address.mode === 'continuable')
-  const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
-  const [busy, setBusy] = useState<QueueItemId | null>(null)
+  const [editing, setEditing] = useState<{ id: MessageId; text: string } | null>(null)
+  const [busy, setBusy] = useState<MessageId | null>(null)
   const [collapsed, setCollapsed] = useState(true)
   const listId = useId()
 
@@ -117,7 +138,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
   const listVisible = rowCount === 1 || expanded
 
   const applyAction = async (
-    itemId: QueueItemId,
+    itemId: MessageId,
     action: QueueAction,
     failure: string,
   ): Promise<boolean> => {
@@ -167,6 +188,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
         <ul id={listId} className={css.list} hidden={!listVisible}>
           {listVisible && queue.map((row) => {
             const attachments = queueAttachments(row.content)
+            const text = textOf(row.content)
             return (
               <li key={row.id} className={css.row}>
                 {/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
@@ -213,7 +235,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
                             ))}
                         </span>
                       )}
-                      <span className={css.preview}>{projectUserText(row.preview, [])}</span>
+                      <span className={css.preview}>{projectUserText(previewOf(row.content), [])}</span>
                     </>
                   )}
                 {queueMutable && <div className={css.actions}>
@@ -246,17 +268,17 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que
                     )
                     : (
                       <>
-                        <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
+                        <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={text === null}>
                           <button
                             type="button"
                             className={css.action}
                             aria-label={t('queue.edit')}
                             // Disabled buttons fire no hover events, so the
                             // unsupported hint stays a native title.
-                            title={row.text === null ? t('queue.edit.unsupported') : undefined}
-                            disabled={busy !== null || row.text === null}
+                            title={text === null ? t('queue.edit.unsupported') : undefined}
+                            disabled={busy !== null || text === null}
                             onClick={() => {
-                              if (row.text !== null) setEditing({ id: row.id, text: row.text })
+                              if (text !== null) setEditing({ id: row.id, text: text })
                             }}
                           >
                             <IconEditOutline16 size={14} />

+ 4 - 3
packages/client/ui-conversation/src/client/service.ts

@@ -24,7 +24,8 @@ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type {
   ComposerAttachment, ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload,
 } from './contract/slots.ts'
-import type { QueueAction, QueueItemId } from './contract/queue.ts'
+import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { ComposerBlocks } from './contract/composer-blocks.ts'
 import type {
   DraftAttachmentId, DraftAttachmentSerializationResult, SessionInputResolver, SubmitAttachment, SubmitOutcome,
@@ -56,7 +57,7 @@ export interface IConversation {
    * @param action - requested queue operation.
    * @returns completion; converged QueueDock races resolve, while other failures reject.
    */
-  updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
+  updateQueue(itemId: MessageId, action: QueueAction): Promise<void>
   /**
    * Cancel the scoped session's in-flight turn while preserving its pending Queue.
    * @returns completion; failures reject as in send.
@@ -491,7 +492,7 @@ export class ConversationController extends Service implements IConversation {
   }
 
   /** Apply one operation to a pending queue occurrence. */
-  async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
+  async updateQueue(itemId: MessageId, action: QueueAction): Promise<void> {
     const session = this.scopedSession('updateQueue')
     const result = await session.updateQueue(itemId, action)
     if (!result.ok) {

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -139,7 +139,7 @@ export const InputBar = memo(function InputBar({
   const editable = live && !locked && !machineBusy
   const steeringAvailable = subagent === null || subagent.address.mode === 'continuable'
   const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable
-    && input.queue.some(row => row.placement === 'queued')
+    && input.queue.length > 0
 
   useEffect(() => {
     if (input === undefined || inputActions === undefined) return

+ 0 - 1
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -24,7 +24,6 @@ afterEach(() => { vi.unstubAllGlobals() })
 function sessionSnapshot(): SessionSnapshot {
   return {
     sessionId: SESSION_ID,
-    queue: [],
     pendingSubmissions: [],
     running: false,
     subagent: null,

+ 11 - 10
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -8,6 +8,7 @@
 // root listener routes them through the keymap commands); draft writes drive
 // the shell (jsdom's beforeinput lacks the ranges Lexical needs).
 
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
 import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -80,7 +81,8 @@ interface BenchOptions {
   onRequestWorkspace?: () => void
   promptError?: SessionSnapshot['promptError']
   /** Authoritative queue rows served to the machine overlay (empty = none). */
-  queue?: SessionSnapshot['queue']
+  queue?: InboxState['next-turn']
+  nextStep?: InboxState['next-step']
   /** The hub's steer-all face (empty-draft accelerated Enter). */
   steerQueue?: () => void
   variant?: 'hero' | 'composer'
@@ -101,10 +103,10 @@ interface BenchOptions {
 }
 
 /** One pending queue row (the runtime snapshot shape, as the dock tests build it). */
-function row(id: string): SessionSnapshot['queue'][number] {
+function row(id: string): InboxState['next-turn'][number] {
   return {
-    id: id as never, messageId: `message-${id}` as never, placement: 'queued',
-    content: [{ type: 'text', text: id }], preview: id, text: id,
+    id: id as never, role: 'user', source: { kind: 'user' },
+    content: [{ type: 'text', text: id }],
   }
 }
 
@@ -122,17 +124,16 @@ function bench(over?: BenchOptions) {
     subagent: over?.subagent ?? null,
     removed: over?.disabled ?? false,
     promptError: over?.promptError ?? null,
-    queue: over?.queue ?? [],
   }))
   type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
   const shell = new SessionInputShell({
     actx: SCTX,
     defaultSink: sink,
     commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` },
-    queue: {
-      getSnapshot: () => session.getSnapshot().queue,
-      subscribe: fn => session.subscribe(fn),
-    },
+    inbox: createSnapshotStore<InboxState>({
+      'next-turn': over?.queue ?? [],
+      'next-step': over?.nextStep ?? [],
+    }),
     ...(over?.steerQueue !== undefined ? { steerQueue: over.steerQueue } : {}),
     // Lexicon-only stub: adjudication untouched (undefined slash methods are
     // never reached — these benches drive plain-draft flows only).
@@ -665,7 +666,7 @@ describe('Enter semantics', () => {
     // Pending steering rows are not the queue: nothing to flush.
     const steering = bench({
       running: true,
-      queue: [{ ...row('s-1'), placement: 'steering' }],
+      nextStep: [row('s-1')],
       steerQueue: vi.fn(),
     })
     fireEvent.keyDown(steering.textarea, { key: 'Enter', metaKey: true })

+ 19 - 0
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -122,6 +122,25 @@ function bench(over?: {
 }
 
 describe('matrix row: plain', () => {
+  it('unsubscribes from the Inbox projection when disposed', () => {
+    const unsubscribe = vi.fn()
+    const subscribe = vi.fn(() => unsubscribe)
+    const shell = new SessionInputShell({
+      actx: SCTX,
+      defaultSink: () => Promise.resolve({ kind: 'success' }),
+      inbox: { getSnapshot: () => undefined, subscribe },
+      commandAttachments: {
+        serialize: () => Promise.resolve([]),
+        release: () => {},
+        unsupportedNotice: token => `${token.trim()} attachments-unsupported`,
+      },
+    })
+
+    expect(subscribe).toHaveBeenCalledOnce()
+    shell.dispose()
+    expect(unsubscribe).toHaveBeenCalledOnce()
+  })
+
   it('enter falls to the default sink; no claim on the currency; edits free', async () => {
     const { textarea, shell, sink } = bench()
     act(() => { shell.setDraft('普通消息') })

+ 82 - 49
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -8,8 +8,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { useSyncExternalStore } from 'react'
 import type {
-  QueuedMessage, SessionListState, SessionSnapshot,
+  SessionListState, SessionSnapshot, UseProjection,
 } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { InboxState } from '@deepseek-ai/dsh-agent/types'
+import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -18,7 +21,6 @@ import {
 } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import type { QueueItemId } from '../src/client/contract/queue.ts'
 import type { InputState } from '../src/client/contract/input.ts'
 import { zh } from '../src/client/locales.ts'
 import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
@@ -30,27 +32,36 @@ afterEach(cleanup)
 
 
 const SID = 's1' as SessionId
-const iid = (id: string): QueueItemId => id as QueueItemId
+const iid = (id: string): MessageId => id as MessageId
 
-function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
+function row(id: string, text: string | null, preview = text ?? '[image]'): UserMessage {
   return {
-    id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
-    content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }],
-    preview, text,
+    id: iid(id), role: 'user', source: { kind: 'user' },
+    content: text === null
+      ? [
+        ...(preview === '[image]' ? [] : [{ type: 'text' as const, text: preview.replace(/ \[image\]$/u, '') }]),
+        { type: 'image', data: 'x' } as never,
+      ]
+      : [{ type: 'text', text }],
   }
 }
 
-function snapshotWith(queue: QueuedMessage[]): SessionSnapshot {
+interface TestSnapshot extends SessionSnapshot {
+  readonly testInbox: InboxState
+}
+
+function snapshotWith(queue: UserMessage[], nextStep: UserMessage[] = []): TestSnapshot {
   return {
-    sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null,
+    sessionId: SID, running: true, removed: false, openState: 'open', openError: null,
     hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null,
     pendingSubmissions: [],
     lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false,
+    testInbox: { 'next-turn': queue, 'next-step': nextStep },
   }
 }
 
-/** Minimal live source backing the useSession stub. */
-function liveSession(initial: SessionSnapshot) {
+/** Minimal live source backing the Session and Inbox projection hooks. */
+function liveSession(initial: TestSnapshot) {
   let snapshot = initial
   const listeners = new Set<() => void>()
   const useSession: SnapshotSelectorHook<SessionSnapshot> = selector =>
@@ -61,9 +72,20 @@ function liveSession(initial: SessionSnapshot) {
       },
       () => selector(snapshot),
     )
+  const useProjection = ((
+    key: string,
+    selector: (value: InboxState | undefined) => unknown = value => value,
+  ) => useSyncExternalStore(
+    (listener) => {
+      listeners.add(listener)
+      return () => listeners.delete(listener)
+    },
+    () => selector(key === 'inbox' ? snapshot.testInbox : undefined),
+  )) as UseProjection
   return {
     useSession,
-    push(next: SessionSnapshot): void {
+    useProjection,
+    push(next: TestSnapshot): void {
       snapshot = next
       for (const listener of [...listeners]) listener()
     },
@@ -102,9 +124,9 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected>
 }
 
 /** One queued row carrying a durable image reference (plus optional leading text). */
-function imageRow(id: string, refId: string, text = ''): QueuedMessage {
+function imageRow(id: string, refId: string, text = ''): UserMessage {
   return {
-    id: iid(id), messageId: `message-${id}` as never, placement: 'queued',
+    id: iid(id), role: 'user', source: { kind: 'user' },
     content: [
       ...text === '' ? [] : [{ type: 'text' as const, text }],
       {
@@ -112,7 +134,6 @@ function imageRow(id: string, refId: string, text = ''): QueuedMessage {
         attachment: { attachmentId: refId, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
       } as never,
     ],
-    preview: text, text: null,
   }
 }
 
@@ -120,7 +141,7 @@ describe('QueueDock', () => {
   it('renders null while the queue is empty', () => {
     const snap = snapshotWith([])
     const source = liveSession(snap)
-    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(container.innerHTML).toBe('')
   })
 
@@ -150,7 +171,7 @@ describe('QueueDock', () => {
     }
     const source = liveSession(pending)
     const props = kitFor(pending)
-    const view = render(<QueueDock {...props} useSession={source.useSession} />)
+    const view = render(<QueueDock {...props} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull()
     expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview')
     expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB')
@@ -166,7 +187,7 @@ describe('QueueDock', () => {
     act(() => {
       source.push({
         ...pending,
-        queue: [{ ...row('accepted', '等待上传'), rpcId: 'req-local-queue' as never }],
+        testInbox: { 'next-turn': [{ ...row('accepted', '等待上传'), source: { kind: 'user', rpcId: 'req-local-queue' as never } }], 'next-step': [] },
       })
     })
     expect(view.getAllByText('等待上传')).toHaveLength(1)
@@ -180,7 +201,7 @@ describe('QueueDock', () => {
   })
 
   it('loads the durable thumbnail after replacing a local image echo', async () => {
-    const pending: SessionSnapshot = {
+    const pending: TestSnapshot = {
       ...snapshotWith([]),
       pendingSubmissions: [{
         requestId: 'req-image' as never, placement: 'queued', time: 1,
@@ -193,14 +214,16 @@ describe('QueueDock', () => {
     const image = Promise.withResolvers<string>()
     const loadImage = vi.fn(() => image.promise)
     const source = liveSession(pending)
-    const view = render(<QueueDock {...kitFor(pending, { loadImage })} useSession={source.useSession} />)
+    const view = render(
+      <QueueDock {...kitFor(pending, { loadImage })} useSession={source.useSession} useProjection={source.useProjection} />,
+    )
     expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:local-preview')
     expect(loadImage).not.toHaveBeenCalled()
 
     act(() => {
       source.push({
         ...pending,
-        queue: [{ ...imageRow('accepted-image', 'durable-image', 'queued image'), rpcId: 'req-image' as never }],
+        testInbox: { 'next-turn': [{ ...imageRow('accepted-image', 'durable-image', 'queued image'), source: { kind: 'user', rpcId: 'req-image' as never } }], 'next-step': [] },
       })
     })
     expect(view.container.querySelector('[data-submission-echo]')).toBeNull()
@@ -216,7 +239,7 @@ describe('QueueDock', () => {
   })
 
   it('keeps sending status visible while a queue containing local submissions is collapsed', () => {
-    const pending: SessionSnapshot = {
+    const pending: TestSnapshot = {
       ...snapshotWith([row('accepted', '已排队')]),
       pendingSubmissions: [{
         requestId: 'req-waiting' as never, placement: 'queued', time: 1,
@@ -224,7 +247,7 @@ describe('QueueDock', () => {
       }],
     }
     const source = liveSession(pending)
-    const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(pending)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(view.getByRole('status').textContent).toBe('发送中…')
     const header = view.getByRole('button', { name: /2 条排队消息\s*发送中…/ })
     expect(header.getAttribute('aria-expanded')).toBe('false')
@@ -236,17 +259,16 @@ describe('QueueDock', () => {
   })
 
   it('leaves pending steering to the conversation flow', () => {
-    const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const }
-    const snap = snapshotWith([steering])
+    const snap = snapshotWith([], [row('s-1', 'interrupt')])
     const source = liveSession(snap)
-    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(container.innerHTML).toBe('')
   })
 
   it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
     const single = snapshotWith([row('i-1', 'one')])
     const source = liveSession(single)
-    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
     expect(view.getByText('one')).toBeTruthy()
 
@@ -270,7 +292,7 @@ describe('QueueDock', () => {
   it('keeps an active single-row editor visible when another item arrives', () => {
     const single = snapshotWith([row('i-edit', 'before')])
     const source = liveSession(single)
-    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} useProjection={source.useProjection} />)
 
     fireEvent.click(view.getByLabelText('编辑排队消息'))
     fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
@@ -296,7 +318,7 @@ describe('QueueDock', () => {
     let finishUpdate: (() => void) | undefined
     const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
     const view = render(
-      <QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(view.getByLabelText('删除排队消息'))
@@ -324,7 +346,7 @@ describe('QueueDock', () => {
   it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
     const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
     const source = liveSession(first)
-    const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} useProjection={source.useProjection} />)
     fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
     expect(view.getByText('one')).toBeTruthy()
 
@@ -339,16 +361,28 @@ describe('QueueDock', () => {
     expect(view.queryByText('three')).toBeNull()
   })
 
+  it('flattens and caps previews at 200 code points while preserving complete editable text', () => {
+    const text = `  before   ${'🙂'.repeat(201)}  after`
+    const snap = snapshotWith([row('long-preview', text)])
+    const source = liveSession(snap)
+    const view = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
+    expect(view.getByText(`before ${'🙂'.repeat(193)}…`)).toBeTruthy()
+    fireEvent.click(view.getByLabelText('编辑排队消息'))
+    expect((view.getByRole('textbox') as HTMLInputElement).value).toBe(text)
+  })
+
   it('renders active actions and disables editing for mixed-content rows', () => {
     const snap = snapshotWith([
       row('i-1', '第一条排队消息'),
       row('i-2', null, 'image [image]'),
     ])
     const source = liveSession(snap)
-    const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container, getByRole } = render(
+      <QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />,
+    )
     fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
     expect([...container.querySelectorAll('li')].map(item => item.textContent))
-      .toEqual(['第一条排队消息', 'image [image]'])
+      .toEqual(['第一条排队消息', 'image'])
     expect(container.querySelectorAll('button')).toHaveLength(7)
     expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
     expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
@@ -364,7 +398,7 @@ describe('QueueDock', () => {
     const snap = snapshotWith([imageRow('i-img', 'att-9', '带图消息')])
     const source = liveSession(snap)
     const { container } = render(
-      <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     await waitFor(() => {
@@ -377,8 +411,8 @@ describe('QueueDock', () => {
 
   it('renders durable files and images in their original queue order', async () => {
     const loadImage = vi.fn(() => Promise.resolve('blob:mixed'))
-    const mixed: QueuedMessage = {
-      id: iid('i-mixed'), messageId: 'message-i-mixed' as never, placement: 'queued',
+    const mixed: UserMessage = {
+      id: iid('i-mixed'), role: 'user', source: { kind: 'user' },
       content: [
         {
           type: 'file',
@@ -392,11 +426,10 @@ describe('QueueDock', () => {
           },
         },
       ],
-      preview: '', text: null,
     }
     const snap = snapshotWith([mixed])
     const source = liveSession(snap)
-    const view = render(<QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />)
+    const view = render(<QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} useProjection={source.useProjection} />)
     await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
     const group = view.getByLabelText('排队文件 report.csv').parentElement
     expect(group?.children).toHaveLength(2)
@@ -409,7 +442,7 @@ describe('QueueDock', () => {
     const snap = snapshotWith([imageRow('i-broken', 'att-x')])
     const source = liveSession(snap)
     const { container } = render(
-      <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     await act(async () => { await Promise.resolve() })
@@ -423,7 +456,7 @@ describe('QueueDock', () => {
     const snap = snapshotWith([imageRow('i-late', 'att-late')])
     const source = liveSession(snap)
     const { unmount } = render(
-      <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { loadImage })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     unmount()
@@ -439,7 +472,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getByLabelText, queryByLabelText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('编辑排队消息'))
@@ -463,7 +496,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getByLabelText, getByText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('编辑排队消息'))
@@ -482,7 +515,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getByLabelText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('编辑排队消息'))
@@ -500,7 +533,7 @@ describe('QueueDock', () => {
     const source = liveSession(snap)
     const updateQueue = vi.fn(() => Promise.resolve())
     const { getAllByLabelText, getByRole } = render(
-      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
@@ -515,7 +548,7 @@ describe('QueueDock', () => {
     const source = liveSession(running)
     const updateQueue = vi.fn(() => Promise.resolve())
     const rendered = render(
-      <QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(running, { updateQueue })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     const button = rendered.getByLabelText('插话发送')
@@ -544,7 +577,7 @@ describe('QueueDock', () => {
     }
     const source = liveSession(snap)
     const view = render(
-      <QueueDock {...kitFor(snap)} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     expect(view.getByText('pending child follow-up')).toBeTruthy()
@@ -567,7 +600,7 @@ describe('QueueDock', () => {
     }
     const source = liveSession(snap)
     const view = render(
-      <QueueDock {...kitFor(snap)} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     expect(view.getByText('pending child follow-up')).toBeTruthy()
@@ -582,7 +615,7 @@ describe('QueueDock', () => {
     const notify = vi.fn()
     const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed')))
     const { getByLabelText, getByText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('插话发送'))
@@ -601,7 +634,7 @@ describe('QueueDock', () => {
     const notify = vi.fn()
     const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
     const { getByLabelText, getByText } = render(
-      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
+      <QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} useProjection={source.useProjection} />,
     )
 
     fireEvent.click(getByLabelText('删除排队消息'))
@@ -614,7 +647,7 @@ describe('QueueDock', () => {
   it('follows authoritative retirement back to null', () => {
     const snap = snapshotWith([row('i-1', '在场')])
     const source = liveSession(snap)
-    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
+    const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} useProjection={source.useProjection} />)
     expect(container.textContent).toContain('在场')
     act(() => { source.push(snapshotWith([])) })
     expect(container.innerHTML).toBe('')

+ 15 - 23
packages/client/ui-conversation/tests/service-orchestration.client.spec.ts

@@ -3,11 +3,13 @@
 // TestSessions mints tagged scopes through the production createScope, so the
 // service's scopeOf/binding path runs against production resolution (no local
 // tag probe).
+import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import type {
-  BeginSubmissionInput, PendingSubmissionRetirement, QueuedMessage,
+  BeginSubmissionInput, PendingSubmissionRetirement,
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
@@ -80,15 +82,15 @@ describe('ConversationController', () => {
   it('treats QueueDock Steer pre-admission races as converged Queue delivery', async () => {
     const b = await bench()
     b.updateQueue.mockResolvedValueOnce({
-      ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }),
+      ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as MessageId }),
     } as never)
     await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined()
     b.updateQueue.mockResolvedValueOnce({
-      ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }),
+      ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as MessageId }),
     } as never)
     await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined()
     b.updateQueue.mockResolvedValueOnce({
-      ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }),
+      ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as MessageId }),
     } as never)
     await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' }))
       .rejects.toThrow('conversation.updateQueue failed: session/queue-item-not-found: claimed')
@@ -783,20 +785,16 @@ describe('draft image dimension probe', () => {
 })
 
 describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
-  const row = (id: string): QueuedMessage => ({
+  const row = (id: string): UserMessage => ({
     id: id as never,
-    messageId: `message-${id}` as never,
-    placement: 'queued',
+    role: 'user',
+    source: { kind: 'user' },
     content: [{ type: 'text', text: id }],
-    preview: id,
-    text: id,
   })
 
   it('steers every queued row in FIFO order and leaves steering rows alone', async () => {
     const b = await bench()
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-1'), { ...row('q-2'), placement: 'steering' }, row('q-3')]
-    })
+    await b.runtime.sessions.setProjection('s1', 'inbox', { 'next-turn': [row('q-1'), row('q-3')], 'next-step': [row('q-2')] })
     b.shell.steerQueue()
     await vi.waitFor(() => {
       expect(b.updateQueue).toHaveBeenCalledTimes(2)
@@ -809,12 +807,10 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
 
   it('converges silently when the turn closes or a row is claimed mid-steer', async () => {
     const b = await bench()
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-1'), row('q-2')]
-    })
+    await b.runtime.sessions.setProjection('s1', 'inbox', { 'next-turn': [row('q-1'), row('q-2')], 'next-step': [] })
     // The turn closes before the second row: the flush stops, silently.
     b.updateQueue.mockResolvedValueOnce({
-      ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as QueuedMessage['id'] }),
+      ok: false, error: new RemoteError('session/steer-unavailable', 'closed', { itemId: 'item-1' as MessageId }),
     } as never)
     b.shell.steerQueue()
     await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(1) })
@@ -822,11 +818,9 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
 
     // A row the host already claimed (e.g. a repeated empty-draft chord):
     // the duplicate Steer is a silent no-op.
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-3')]
-    })
+    await b.runtime.sessions.setProjection('s1', 'inbox', { 'next-turn': [row('q-3')], 'next-step': [] })
     b.updateQueue.mockResolvedValueOnce({
-      ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as QueuedMessage['id'] }),
+      ok: false, error: new RemoteError('session/queue-item-not-found', 'claimed', { itemId: 'item-1' as MessageId }),
     } as never)
     b.shell.steerQueue()
     await vi.waitFor(() => { expect(b.updateQueue).toHaveBeenCalledTimes(2) })
@@ -836,9 +830,7 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
 
   it('surfaces one notice on a genuine steer failure and stops', async () => {
     const b = await bench()
-    await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
-      draft.queue = [row('q-1'), row('q-2')]
-    })
+    await b.runtime.sessions.setProjection('s1', 'inbox', { 'next-turn': [row('q-1'), row('q-2')], 'next-step': [] })
     b.updateQueue.mockResolvedValueOnce({
       ok: false, error: new RemoteError('gateway/internal', 'broken', {}),
     } as never)

+ 3 - 0
packages/client/ui-conversation/tsconfig.json

@@ -47,6 +47,9 @@
     {
       "path": "../ui-session"
     },
+    {
+      "path": "../../core/agent"
+    },
     {
       "path": "../../core/session"
     },

+ 0 - 1
packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx

@@ -51,7 +51,6 @@ function record(
 function sessionSnapshot(openState: SessionSnapshot['openState']): SessionSnapshot {
   return {
     sessionId: SESSION,
-    queue: [],
     pendingSubmissions: [],
     running: false,
     subagent: null,

+ 0 - 1
packages/client/ui-session/tests/ui-session.client.spec.ts

@@ -73,7 +73,6 @@ function createSessionsBench(_ctx: Context): SessionsBench {
       const scopeCtx = new Context()
       const snapshot = createSnapshotStore<SessionSnapshot>({
         sessionId: id,
-        queue: [],
         pendingSubmissions: [],
         running: false,
         subagent: null,

+ 0 - 1
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -117,7 +117,6 @@ function historySnapshot(
 function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapshot {
   return {
     sessionId: SID,
-    queue: [],
     pendingSubmissions: [],
     running: false,
     subagent: null,

+ 0 - 1
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -33,7 +33,6 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
 
 const sessionState: SessionState = {
   sessionId: SID,
-  queue: [],
   pendingSubmissions: [],
   running: false,
   subagent: null,

+ 0 - 1
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -32,7 +32,6 @@ type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPen
 
 const sessionState: SessionState = {
   sessionId: SID,
-  queue: [],
   pendingSubmissions: [],
   running: false,
   subagent: null,

+ 2 - 2
packages/core/agent-loop/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-loop/README.md
-README.md: e53ccb4658ee857f7798ee18343752416b93a890
-README.zh.md: 3974bc56960d9f5c39082affc52937fc9f8b1946
+README.md: 7c3073aefb90fa2ec74d521d6c9d97aff5f74ace
+README.zh.md: 44621a0b02eadfc574f6f6b924f64cfeb4141cd3

File diff suppressed because it is too large
+ 0 - 0
packages/core/agent-loop/README.md


File diff suppressed because it is too large
+ 0 - 0
packages/core/agent-loop/README.zh.md


+ 2 - 5
packages/core/agent-loop/src/inbox.ts

@@ -67,7 +67,7 @@ export const inboxProjectionDefinition = {
 /**
  * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused
  * provider tests.
- * @param projections - registry that owns the standard Inbox projection.
+ * @param projections - registry with the standard Inbox projection registered by AgentLoop.
  * @param session - session whose durable events store pending input.
  * @param dispatch - agent-scoped notifications for Inbox lifecycle events.
  */
@@ -76,9 +76,7 @@ export class ReactLoopInbox implements InboxContract {
     private readonly projections: SessionProjectionRegistry,
     private readonly session: Session,
     private readonly dispatch: AgentEventDispatch,
-  ) {
-    this.projections.register(inboxProjectionDefinition)
-  }
+  ) {}
 
   /** Prompts awaiting individual turns. */
   get nextTurn(): readonly UserMessage[] {
@@ -188,7 +186,6 @@ export class ReactLoopInbox implements InboxContract {
   /** Read the current durable projection state. */
   private current(): InboxState {
     const state = this.projections.stateOf(this.session, 'inbox')
-    /* v8 ignore next -- the constructor registers this key before any read */
     if (state === undefined) {
       throw new Error(
         `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`,

Some files were not shown because too many files changed in this diff