浏览代码

fix(web): reconcile durable inbox recovery with master

_Kerman 1 周之前
父节点
当前提交
72f2e71070
共有 72 个文件被更改,包括 478 次插入850 次删除
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md
  4. 2 2
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml
  5. 2 2
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
  6. 2 2
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md
  7. 75 0
      apps/web/tests/cold-blank-session.e2e.ts
  8. 1 0
      apps/web/tsconfig.json
  9. 2 2
      docs/architecture.i18n.yaml
  10. 1 1
      docs/architecture.md
  11. 1 1
      docs/architecture.zh.md
  12. 2 2
      docs/config-catalog.i18n.yaml
  13. 1 1
      docs/config-catalog.md
  14. 1 1
      docs/config-catalog.zh.md
  15. 2 2
      docs/event-producer-consumer.i18n.yaml
  16. 6 6
      docs/event-producer-consumer.md
  17. 6 6
      docs/event-producer-consumer.zh.md
  18. 2 2
      docs/subsystems/core.i18n.yaml
  19. 1 1
      docs/subsystems/core.md
  20. 1 1
      docs/subsystems/core.zh.md
  21. 2 2
      packages/api/session-controller/README.i18n.yaml
  22. 0 0
      packages/api/session-controller/README.md
  23. 1 1
      packages/api/session-controller/README.zh.md
  24. 0 15
      packages/api/session-controller/src/client/contract/snapshot.ts
  25. 3 25
      packages/api/session-controller/src/client/sessions/manager.ts
  26. 0 71
      packages/api/session-controller/src/client/sessions/queue-mirror.ts
  27. 27 37
      packages/api/session-controller/src/client/sessions/session.ts
  28. 9 4
      packages/api/session-controller/src/commands.ts
  29. 3 45
      packages/api/session-controller/src/control.ts
  30. 0 15
      packages/api/session-controller/src/types.ts
  31. 29 9
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  32. 1 1
      packages/api/session-controller/tests/commands-upload-file.host.spec.ts
  33. 31 41
      packages/api/session-controller/tests/control-queue.host.spec.ts
  34. 0 297
      packages/api/session-controller/tests/queue-store.client.spec.ts
  35. 22 36
      packages/api/session-controller/tests/session-cold.host.spec.ts
  36. 33 18
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  37. 4 1
      packages/api/session-controller/tests/session-projections.host.spec.ts
  38. 6 5
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  39. 37 37
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  40. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  41. 1 1
      packages/client/ui-conversation/README.md
  42. 1 1
      packages/client/ui-conversation/README.zh.md
  43. 2 1
      packages/client/ui-conversation/package.json
  44. 0 11
      packages/client/ui-conversation/src/client/contract/queue.ts
  45. 10 6
      packages/client/ui-conversation/src/client/input/facade.ts
  46. 5 3
      packages/client/ui-conversation/src/client/input/hub.ts
  47. 0 25
      packages/client/ui-conversation/src/client/input/queue-store.ts
  48. 38 16
      packages/client/ui-conversation/src/client/queue/QueueDock.tsx
  49. 4 3
      packages/client/ui-conversation/src/client/service.ts
  50. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  51. 0 1
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  52. 11 10
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  53. 2 2
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  54. 29 19
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  55. 15 23
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  56. 0 1
      packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx
  57. 0 1
      packages/client/ui-session/tests/ui-session.client.spec.ts
  58. 0 1
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  59. 0 1
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  60. 0 1
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  61. 2 2
      packages/core/agent-loop/README.i18n.yaml
  62. 0 0
      packages/core/agent-loop/README.md
  63. 0 0
      packages/core/agent-loop/README.zh.md
  64. 2 4
      packages/core/agent-loop/src/inbox.ts
  65. 2 0
      packages/core/agent-loop/src/index.ts
  66. 6 2
      packages/core/agent-loop/tests/inbox.spec.ts
  67. 4 2
      packages/core/agent-loop/tests/scope-lifecycle.spec.ts
  68. 5 1
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  69. 8 8
      packages/extensions/tool-cordis/src/api-catalog.ts
  70. 0 1
      packages/test-support/client-runtime/src/fixtures.ts
  71. 3 0
      pnpm-lock.yaml
  72. 1 0
      tsconfig.host.json

+ 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: de58747423fe71a7f04cd9354d39e7eb215f048e
+2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 9103e5c6f5197ecb74269cde249d610c0396354f

+ 3 - 3
.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` and reads the definition registered by `AgentLoop` at service activation. This service lifetime keeps cold Inbox reads available without any live Agent.
 
-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
 

+ 3 - 3
.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 读取仍然可用
 
-两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `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 镜像。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.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/bug-fix/2026-08-17-durable-web-queue-recovery.md
-2026-08-17-durable-web-queue-recovery.md: feea3b9c02fe5ce81bb6a8a453e83db658616ecd
-2026-08-17-durable-web-queue-recovery.zh.md: 35c5bea725bb632a587aba407535192ccb5eca29
+2026-08-17-durable-web-queue-recovery.md: ffc699c7bc793da10e1f261be9ff6f7cfe079c59
+2026-08-17-durable-web-queue-recovery.zh.md: 789131c3ddfa944e43fccd71ac2ad4c39a418882

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

@@ -12,9 +12,9 @@ A reconnect-only repair would retain two recovery implementations: one for a liv
 
 ## Decision
 
-`AgentRegistry` registers the standard `inbox` projection whenever the Session projection registry is composed. The projection folds normalized `agent/inbox/spliced` operations from the complete session log into the complete `{ 'next-turn', 'next-step' }` state and exposes that same state as its value. A fork therefore projects pending Inbox operations inherited in its seed, like every other projection. Its schema and `InboxState` type have one definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator.
+`AgentLoop` registers the standard `inbox` projection whenever the Session projection registry is composed. The projection folds normalized `agent/inbox/spliced` operations from the complete session log into the complete `{ 'next-turn', 'next-step' }` state and exposes that same state as its value. A fork therefore projects pending Inbox operations inherited in its seed, like every other projection. Its schema and `InboxState` type have one definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator.
 
-The projection registry owns both durable reconstruction and live state. Its existing `session/event` drive folds each committed splice before `Session.append()` returns. Each Agent's `Inbox` command facade reads that same registry state. Inbox normalizes splice coordinates and checks pending `MessageId` uniqueness before append. It does not traverse `session.events`, copy the folded arrays, or apply the committed transition again. It emits the per-message `inserted`, `discarded`, and `claimed` live notifications owned by its mutations.
+The projection registry owns both durable reconstruction and live state. Its existing `session/event` drive folds each committed splice before `Session.append()` returns. Each Agent's `ReactLoopInbox` command facade reads that same registry state. Inbox normalizes splice coordinates and checks pending `MessageId` uniqueness before append. It does not traverse `session.events`, copy the folded arrays, or apply the committed transition again. It emits the per-message `inserted`, `discarded`, and `claimed` live notifications owned by its mutations.
 
 The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block on history tail pages, 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.
 

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

@@ -12,9 +12,9 @@ Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 We
 
 ## 决策
 
-`AgentRegistry` 会在 Session projection registry 已组合时注册标准 `inbox` 投影。该投影从完整会话日志中折叠规范化的 `agent/inbox/spliced` 操作,得到完整的 `{ 'next-turn', 'next-step' }` 状态,并直接暴露同一状态。因此 fork 会像其他投影一样,自然投影其 seed 中继承的待处理 Inbox 操作。其 schema 与 `InboxState` 类型只有一个定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。
+`AgentLoop` 会在 Session projection registry 已组合时注册标准 `inbox` 投影。该投影从完整会话日志中折叠规范化的 `agent/inbox/spliced` 操作,得到完整的 `{ 'next-turn', 'next-step' }` 状态,并直接暴露同一状态。因此 fork 会像其他投影一样,自然投影其 seed 中继承的待处理 Inbox 操作。其 schema 与 `InboxState` 类型只有一个定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。
 
-投影注册表同时拥有持久重建与 live 状态。其既有的 `session/event` 驱动会在 `Session.append()` 返回前折叠每个已提交 splice。每个 Agent 的 `Inbox` 命令 facade 都读取同一份注册表状态。Inbox 在 append 前规范化 splice 坐标并检查待处理 `MessageId` 唯一性。Inbox 不遍历 `session.events`,不复制已折叠数组,也不再次应用已提交 transition。它仍发出其变更拥有的逐消息 `inserted`、`discarded` 与 `claimed` live 通知。
+投影注册表同时拥有持久重建与 live 状态。其既有的 `session/event` 驱动会在 `Session.append()` 返回前折叠每个已提交 splice。每个 Agent 的 `ReactLoopInbox` 命令 facade 都读取同一份注册表状态。Inbox 在 append 前规范化 splice 坐标并检查待处理 `MessageId` 唯一性。Inbox 不遍历 `session.events`,不复制已折叠数组,也不再次应用已提交 transition。它仍发出其变更拥有的逐消息 `inserted`、`discarded` 与 `claimed` live 通知。
 
 通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在历史尾页中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。
 

+ 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([])
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -80,6 +80,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: fed98f006471f86f02c43bcb0c6ea4dfe7850da8
-architecture.zh.md: b1ec7ddaf0278a14cd7c18d6cc272fc59c5c3781
+architecture.md: 2824b77e30f41fddf947d39448ce92879ea0c28a
+architecture.zh.md: 6218f3cefae8fd22f3dc2a5c7fdc27f505233177

+ 1 - 1
docs/architecture.md

@@ -104,7 +104,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.
+Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. The AgentLoop service owns the durable `inbox` projection registration, making pending input readable before any Agent starts and after all Agents unload.
 
 `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 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

@@ -108,7 +108,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 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。AgentLoop 服务拥有持久 `inbox` 投影的注册,因此待处理输入在任何 Agent 启动前以及所有 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: 50626a7d85aa54f6d2e3cdb6140b3af8081d1461
-config-catalog.zh.md: d2ae35b91a4a83da882e8ec5af82a505aa5d5bd1
+config-catalog.md: 72ab9a775077896e6f42af2574cf3b699160219c
+config-catalog.zh.md: a3cabba2637e13e63388e712150a33f9d5f77968

+ 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:318`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:319`](../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:318`](../packages/core/agent-loop/src/index.ts)
+来源:[`packages/core/agent-loop/src/index.ts:319`](../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: 449b7d8f0fb55515e7f1f028e151ce1b26862e92
-event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2
+event-producer-consumer.md: 0adb3ce073c6b22f0c2934db499a4d88612631d3
+event-producer-consumer.zh.md: e6555a780ee071504a062fbe814da55d39da83ab

+ 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:246`](../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:247`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
 | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../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:391`](../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:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:562`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:568`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:575`](../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:87`](../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 @@
 
 | 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
 | --- | --- | --- | --- | --- |
-| `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-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:247`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
 | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
 | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -24,11 +24,11 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../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:391`](../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:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:582`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:562`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:568`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:575`](../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:87`](../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: 27ff30a9e63c86ffb54ccf57dea18ebf3fe39846
-core.zh.md: cedd113de5d5d551b8b558f9f33c7cacf6aa4953
+core.md: 271c2ddfb80186d1ac80244a48ccd3511b7755c3
+core.zh.md: 059304f5c6cbc8f4a8fe24aa9bf6519ec2abf8a0

+ 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
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: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea
-README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8
+README.md: dc1b2a4cf8c322e9cfd9981fd4adc29d1dc544d5
+README.zh.md: 118786581b2611f2dae19cdbb6b07dee91df48d0

文件差异内容过多而无法显示
+ 0 - 0
packages/api/session-controller/README.md


文件差异内容过多而无法显示
+ 1 - 1
packages/api/session-controller/README.zh.md


+ 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

+ 3 - 25
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
@@ -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)
@@ -669,22 +661,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)
@@ -696,9 +678,6 @@ export class SessionManager {
       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 +717,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)

+ 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

@@ -417,11 +417,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(
@@ -438,9 +438,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,

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

@@ -521,19 +521,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
@@ -547,7 +534,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>>
 }
@@ -563,7 +549,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)
 

+ 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]])
   })
 })

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

@@ -1,297 +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, it, 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 { Session } from '../src/client/sessions/session.ts'
-import { SessionManager } from '../src/client/sessions/manager.ts'
-import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
-
-const SID = 'fk-q1' as SessionId
-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,
-    })),
-  }
-}
-
-function makeSession(): Session {
-  return makeBench().session
-}
-
-function makeBench(): { api: FakeApiClient; session: Session } {
-  const api = new FakeApiClient()
-  return { api, session: new Session(SID, fakeRemote(api)) }
-}
-
-function makeManager(): SessionManager {
-  const api = new FakeApiClient()
-  return new SessionManager(fakeRemote(api))
-}
-
-describe('Session queue snapshot intake', () => {
-  it('projects stable ids, flat previews, and complete text', () => {
-    const session = makeSession()
-    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消息',
-      },
-    ])
-  })
-
-  it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => {
-    const session = makeSession()
-    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', () => {
-    const session = makeSession()
-    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', () => {
-    const session = makeSession()
-    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', () => {
-    const session = makeSession()
-    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', () => {
-    const session = makeSession()
-    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 () => {
-    const { api, session } = makeBench()
-    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 api.pushFollow(SID, { type: 'event', event: durable as never })
-    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 api.pushFollow(SID, { type: 'event', event: durable as never })
-    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 () => {
-    const { api, session } = makeBench()
-    await session.open()
-    const message = createUserMessage({
-      content: text('claimed steering'),
-      source: { kind: 'user' },
-    })
-    session.handleControlFrame(queueFrame([
-      { id: 's-claimed', body: '', placement: 'steering', message },
-    ]))
-
-    await api.pushFollow(SID, {
-      type: 'event',
-      event: {
-        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 () => {
-    const api = new FakeApiClient()
-    const session = new Session(SID, fakeRemote(api))
-    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(api.callsOf('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', () => {
-    const session = makeSession()
-    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 () => {
-    const session = makeSession()
-    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', () => {
-    const session = makeSession()
-    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', () => {
-    const manager = makeManager()
-    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', () => {
-    const manager = makeManager()
-    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'])
-  })
-})

+ 22 - 36
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -8,13 +8,13 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SessionStore from '@deepseek-ai/dsh-session'
-import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 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,
@@ -273,8 +274,8 @@ 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 ctx.plugin(SessionStore)
-    await ctx.plugin(AgentRegistry)
+    await mountAgentLoopTestDependencies(ctx)
+    await mountAgentLoopTestHarness(ctx)
     const sessionId = sid('session-cold-queue-mutation')
     const meta = header(sessionId, 1000)
     const message = createUserMessage({
@@ -290,35 +291,19 @@ describe('Remote Agent and Session lookup policy', () => {
     providePersistence(ctx, {
       list: () => Promise.resolve([meta]),
       inspect: () => Promise.resolve({ meta, events }),
-      locate: () => undefined,
-    })
-    let resumedAgent: Agent | undefined
-    const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
-      const session = ctx.sessions.create(sessionId, {
-        seed: events,
-        meta: { cwd: '/proj', createdAt: meta.createdAt },
-      })
-      resumedAgent = {
-        id: session.id,
-        options: {},
-        session,
-        inbox: undefined as never,
-        status: 'idle',
-        ctx,
-        send() {},
-        followup() {},
-        steer() {},
-        inject() {},
-        cancel() {},
-        runMaintenance: task => task(new AbortController().signal),
-        whenIdle: () => Promise.resolve(),
-      } satisfies Agent
-      Object.assign(resumedAgent, {
-        inbox: new Inbox(ctx, resumedAgent.session, agentEvents(ctx, resumedAgent)),
-      })
-      ctx.agents.register(resumedAgent)
-      return { agent: resumedAgent, dispose: () => Promise.resolve() }
+      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',
@@ -332,11 +317,13 @@ describe('Remote Agent and Session lookup policy', () => {
 
     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.events.at(-1)).toMatchObject({
+    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 () => {
@@ -355,7 +342,7 @@ describe('Remote Agent and Session lookup policy', () => {
     }))
 
     expect(response.ok).toBe(false)
-    if (!response.ok) expect(response.error.code).toBe('queue-item-not-found')
+    if (!response.ok) expect(response.error.code).toBe('session/queue-item-not-found')
   })
 
   it('deduplicates a cold resume across Agent and Session parameters', async () => {
@@ -473,8 +460,7 @@ describe('Remote Agent and Session lookup policy', () => {
 
     const resolution = lookup.resolve(sessionId)
 
-    await expect(resolution).rejects.toBeInstanceOf(TypertLookupFailure)
-    await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
+    await expect(resolution).rejects.toMatchObject({ code: 'session/agent-busy' })
   })
 })
 

+ 33 - 18
packages/api/session-controller/tests/session-pending-submissions.client.spec.ts

@@ -7,7 +7,7 @@ import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { Session } from '../src/client/sessions/session.ts'
 import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts'
-import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts'
+import type { SessionRequestId } from '../src/types.ts'
 import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
 import { historyValue } from './event-script.client.ts'
 
@@ -60,21 +60,17 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly At
   } as unknown as SessionEvent
 }
 
-function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem {
-  return {
-    id: 'm-queued' as SessionQueuedItem['id'],
-    placement: 'queued',
-    rpcId,
-    message: {
-      id: 'm-queued' as SessionQueuedItem['id'],
-      content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'],
-    },
-  }
+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', () => {
@@ -185,6 +181,25 @@ 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 () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => Promise.resolve(ok(historyValue([])))
+    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 api.pushFollow(SID, { type: 'event', event: {
+      type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1,
+      data: { target: 'next-step', start: 0, inserted: [message] },
+    } as never })
+    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 () => {
     const { session } = makeSession()
     const retirements: PendingSubmissionRetirement[] = []
@@ -196,12 +211,14 @@ describe('observed retirement', () => {
       onRetire: retirement => retirements.push(retirement),
     })
     const refs = [imageRef('att-q')]
-    session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(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 () => {
@@ -263,9 +280,7 @@ describe('observed retirement', () => {
       attachments: [],
       onRetire: retirement => retirements.push(retirement),
     })
-    session.handleControlFrame({
-      type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])],
-    })
+    session.projections.apply('inbox', { 'next-turn': [queuedItem(handle.requestId, [])], 'next-step': [] }, SessionSeq(1))
     await api.pushFollow(SID, {
       type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never,
     })

+ 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({

+ 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'
@@ -60,10 +61,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,
@@ -82,11 +86,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()
     },
@@ -103,7 +107,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) {
@@ -226,7 +230,7 @@ function bindKeyedSnapshotSelector<Value>(
 
 function makeHarness(
   init: HarnessUpdate = {},
-  sessionOverrides: Partial<SessionSnapshot> = {},
+  sessionOverrides: Partial<TestSessionSnapshot> = {},
   chatSnapshot?: ChatSnapshot,
 ) {
   const {
@@ -242,6 +246,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),
@@ -378,7 +383,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: () => {},
@@ -949,23 +957,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} />)
 
@@ -980,12 +988,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,
           },
@@ -1017,8 +1025,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',
@@ -1028,7 +1036,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)
@@ -1094,15 +1102,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)
@@ -1127,15 +1132,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
@@ -2152,14 +2154,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: 19a210bb200dae359920e85ff944b68242f45b4b
-README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b
+README.md: 7d2b81362964793020581a09c5ff43e8a76129f5
+README.zh.md: 05eadc5e9d022d5b6876f788602fd1e700ce2f94

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

@@ -44,7 +44,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis
 
 The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated.
 
-The resident composer survives no-Session and Session transitions. 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. 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

@@ -44,7 +44,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是
 
 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。
 
-常驻 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` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
 

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

@@ -93,7 +93,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",

+ 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'
@@ -23,7 +24,7 @@ import { mergeRegister } from '@lexical/utils'
 import type {
   ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId,
   InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome,
-  Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
+  Occurrence, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment,
   SubmitOutcome, TokenSpan,
 } from '../contract/input.ts'
 import type { InputSubmitMode } from '../contract/composer-submission.ts'
@@ -53,8 +54,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.
@@ -97,7 +98,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()
@@ -170,6 +171,8 @@ export class SessionInputShell implements SessionInput {
     readonly attachmentIds: readonly DraftAttachmentId[]
   }>()
 
+  private readonly unsubscribeInbox: (() => void) | undefined
+
   constructor(private readonly deps: SessionInputDeps) {
     this.editor = createEditor({
       namespace: 'dsh-composer',
@@ -185,7 +188,7 @@ export class SessionInputShell implements SessionInput {
       () => { this.lexiconOff?.() },
     )
     this.state = createSnapshotStore<InputState>(this.compose())
-    deps.queue?.subscribe(() => { this.publish() })
+    this.unsubscribeInbox = deps.inbox?.subscribe(() => { this.publish() })
   }
 
   // ---- editor plumbing ----
@@ -579,6 +582,7 @@ export class SessionInputShell implements SessionInput {
     }
     this.disposed = true
     this.dispatchRun(({ type: 'release' }))
+    this.unsubscribeInbox?.()
     this.unregister()
     this.editor.setRootElement(null)
     this.detachedDrafts.clear()
@@ -909,7 +913,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 {
   ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController,
   SessionInputResolver, SessionInput, SubmitOutcome,
@@ -87,7 +88,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: {
@@ -199,7 +200,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 {
   DocumentFileIcon, 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

@@ -142,7 +142,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'
@@ -102,10 +104,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 }],
   }
 }
 
@@ -123,17 +125,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).
@@ -624,7 +625,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 })

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

@@ -130,10 +130,10 @@ describe('matrix row: plain', () => {
       actx: SCTX,
       defaultSink: () => Promise.resolve({ kind: 'success' }),
       inbox: { getSnapshot: () => undefined, subscribe },
-      commandImages: {
+      commandAttachments: {
         serialize: () => Promise.resolve([]),
         release: () => {},
-        unsupportedNotice: token => `${token.trim()} images-unsupported`,
+        unsupportedNotice: token => `${token.trim()} attachments-unsupported`,
       },
     })
 

+ 29 - 19
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -124,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 }],
       {
@@ -134,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,
   }
 }
 
@@ -172,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')
@@ -188,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)
@@ -202,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,
@@ -215,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()
@@ -238,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,
@@ -246,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')
@@ -360,6 +361,16 @@ 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', '第一条排队消息'),
@@ -371,7 +382,7 @@ describe('QueueDock', () => {
     )
     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)
@@ -387,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(() => {
@@ -400,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',
@@ -415,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)
@@ -432,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() })
@@ -446,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()
@@ -567,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()

+ 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'
@@ -81,15 +83,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')
@@ -784,20 +786,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)
@@ -810,12 +808,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) })
@@ -823,11 +819,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) })
@@ -837,9 +831,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)

+ 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: 31316e4b9259362951824fc13375a820035ba849
-README.zh.md: 06ad80f32fd20d76aa367b8203700235e39072fb
+README.md: baea98aedd37f4f0807648359f2510a67d265744
+README.zh.md: 6ba530b30a3cb25e5483b15e345aa630c4f4fc40

文件差异内容过多而无法显示
+ 0 - 0
packages/core/agent-loop/README.md


文件差异内容过多而无法显示
+ 0 - 0
packages/core/agent-loop/README.zh.md


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

@@ -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,7 @@ 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 */
+    /* v8 ignore next -- AgentLoop registers this key before publishing its factory */
     if (state === undefined) {
       throw new Error(
         `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`,

+ 2 - 0
packages/core/agent-loop/src/index.ts

@@ -33,6 +33,7 @@ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence'
 import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import { ReactLoopAgent } from './agent.ts'
+import { inboxProjectionDefinition } from './inbox.ts'
 import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
 
 /** Fiber states that cannot own or serve a new lifecycle. */
@@ -414,6 +415,7 @@ export class AgentLoop extends Service implements AgentFactory {
     // Register only after every config validation above has passed, so a
     // rejected constructor leaves no projection unit behind.
     ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+    ctx.sessionProjections.register(inboxProjectionDefinition)
     this.ownership = new FactoryOwnership(ctx.fiber)
     this.runtime = { ctx }
     ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')

+ 6 - 2
packages/core/agent-loop/tests/inbox.spec.ts

@@ -5,7 +5,7 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { UserMessage } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { describe, expect, it } from 'vitest'
-import { ReactLoopInbox } from '../src/inbox.ts'
+import { inboxProjectionDefinition, ReactLoopInbox } from '../src/inbox.ts'
 
 function unsupportedInbox(): Agent['inbox'] {
   const rejectMutation = (): never => {
@@ -48,6 +48,7 @@ async function inboxAgent(rawId: string): Promise<{
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionProjectionRegistry)
+  ctx.sessionProjections.register(inboxProjectionDefinition)
   const session = ctx.sessions.create(SessionId(rawId))
   const agent = stubAgent(rawId, { ctx, session })
   const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
@@ -64,6 +65,7 @@ async function reconstructPersistedInbox(
   const session = ctx.sessions.create(SessionId(rawId))
   populate(session)
   await ctx.plugin(SessionProjectionRegistry)
+  ctx.sessionProjections.register(inboxProjectionDefinition)
   const agent = stubAgent(rawId, { ctx, session })
   const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
   try {
@@ -76,10 +78,11 @@ async function reconstructPersistedInbox(
 }
 
 describe('ReactLoopInbox', () => {
-  it('registers the durable projection in its constructor', async () => {
+  it('reads restored pending input from the shared projection', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
+    ctx.sessionProjections.register(inboxProjectionDefinition)
     const session = ctx.sessions.create(SessionId('inbox-projection'))
     const pending = createUserMessage({
       content: [{ type: 'text', text: 'pending' }],
@@ -130,6 +133,7 @@ describe('ReactLoopInbox', () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
+    ctx.sessionProjections.register(inboxProjectionDefinition)
     const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
     const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent })
     const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent))

+ 4 - 2
packages/core/agent-loop/tests/scope-lifecycle.spec.ts

@@ -193,8 +193,10 @@ describe('agent scope lifecycle', () => {
     expect(after.sections.find(s => s.name === 'deployment:persona-prefix')?.text).toBe('You are the deployment.')
   })
 
-  it('keeps the inbox projection until the last owning agent fiber unloads', async () => {
+  it('keeps the inbox projection available after every agent fiber unloads', async () => {
     const ctx = await harness()
+    const cold = ctx.sessions.create(SessionId('projection-before-any-agent'))
+    expect(ctx.sessionProjections.stateOf(cold, 'inbox')).toEqual({ 'next-turn': [], 'next-step': [] })
     let first!: Awaited<ReturnType<typeof ctx.agents.create>>
     let second!: Awaited<ReturnType<typeof ctx.agents.create>>
     const firstOwner = await ctx.plugin(Object.assign(async (inner: Context) => {
@@ -214,7 +216,7 @@ describe('agent scope lifecycle', () => {
     await firstOwner.dispose()
     expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined()
     await secondOwner.dispose()
-    expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeUndefined()
+    expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined()
 
     await Promise.all([first.dispose(), second.dispose()])
     await ctx.fiber.dispose()

+ 5 - 1
packages/extensions/cordis-client-runner/src/client/api-catalog.ts

@@ -726,6 +726,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PropsStore',
     declaration: 'export type PropsStore<H> = H extends StoreHandle<infer T, infer A> ? {\n    useStore: SnapshotSelectorHook<T>;\n    actions: BakedActions<T, A>;\n} : object;',
   },
+  {
+    name: 'QueueAction',
+    declaration: 'export type QueueAction = {\n    readonly kind: \'edit\';\n    readonly content: readonly ContentBlock[];\n} | {\n    readonly kind: \'remove\';\n} | {\n    readonly kind: \'steer\';\n};',
+  },
   {
     name: 'RemoteHostFacts',
     declaration: 'export interface RemoteHostFacts {\n    readonly home: string | undefined;\n    readonly isLoopback: boolean;\n}',
@@ -804,7 +808,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionSnapshot',
-    declaration: 'export interface SessionSnapshot {\n    readonly sessionId: SessionId;\n    readonly queue: readonly QueuedMessage[];\n    readonly pendingSubmissions: readonly PendingSubmission[];\n    readonly running: boolean;\n    readonly subagent: {\n        readonly address: SubagentAddress;\n        readonly parentAvailable?: boolean;\n    } | null;\n    readonly removed: boolean;\n    readonly openState: OpenState;\n    readonly openError: RemoteFailure | null;\n    readonly hasMore: boolean;\n    readonly loadingOlder: boolean;\n    readonly promptError: PromptError | null;\n    readonly blank: boolean;\n    readonly lastAgentError: string | null;\n    readonly promptAttempted: boolean;\n    readonly awaitingFirstTurn: boolean;\n}',
+    declaration: 'export interface SessionSnapshot {\n    readonly sessionId: SessionId;\n    readonly pendingSubmissions: readonly PendingSubmission[];\n    readonly running: boolean;\n    readonly subagent: {\n        readonly address: SubagentAddress;\n        readonly parentAvailable?: boolean;\n    } | null;\n    readonly removed: boolean;\n    readonly openState: OpenState;\n    readonly openError: RemoteFailure | null;\n    readonly hasMore: boolean;\n    readonly loadingOlder: boolean;\n    readonly promptError: PromptError | null;\n    readonly blank: boolean;\n    readonly lastAgentError: string | null;\n    readonly promptAttempted: boolean;\n    readonly awaitingFirstTurn: boolean;\n}',
   },
   {
     name: 'SessionStandardProps',

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

@@ -1525,8 +1525,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the durable attachment reference and base64-encoded bytes.',
       },
       {
-        signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue',
-        description: 'Mutate one still-pending queue occurrence on a live Agent.',
+        signature: '@Remote(\'updateQueue\') updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue>',
+        description: 'Mutate one still-pending queue occurrence, resuming a cold Agent first.',
         parameters: [{ name: 'request', description: 'Session, queue item, and requested mutation.' }],
         returns: 'acknowledgement that the queue mutation was applied.',
       },
@@ -4835,6 +4835,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PtcDispatchLog',
     declaration: 'export interface PtcDispatchLog {\n    readonly exec: ToolExecution;\n    readonly agent?: Agent;\n    readonly subCallId: ToolCallId;\n    readonly name: string;\n    readonly isError: boolean;\n    readonly content: ContentBlock[];\n}',
   },
+  {
+    name: 'QueueAction',
+    declaration: 'export type QueueAction = {\n    readonly kind: \'edit\';\n    readonly content: readonly ContentBlock[];\n} | {\n    readonly kind: \'remove\';\n} | {\n    readonly kind: \'steer\';\n};',
+  },
   {
     name: 'ReadFileLine',
     declaration: 'export interface ReadFileLine {\n    number: number;\n    text: string;\n}',
@@ -5057,11 +5061,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionControlBaseline',
-    declaration: 'export interface SessionControlBaseline {\n    readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>;\n    readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>;\n    readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>;\n}',
+    declaration: 'export interface SessionControlBaseline {\n    readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>;\n    readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>;\n}',
   },
   {
     name: 'SessionControlFrame',
-    declaration: 'export type SessionControlFrame = {\n    readonly type: \'baseline\';\n    readonly value: SessionControlBaseline;\n} | {\n    readonly type: \'queue\';\n    readonly sessionId: SessionId;\n    readonly items: readonly SessionQueuedItem[];\n} | {\n    readonly type: \'jobs\';\n    readonly sessionId: SessionId;\n    readonly jobs: readonly SessionJob[];\n} | ({\n    readonly type: \'projection\';\n} & SessionProjectionUpdate);',
+    declaration: 'export type SessionControlFrame = {\n    readonly type: \'baseline\';\n    readonly value: SessionControlBaseline;\n} | {\n    readonly type: \'jobs\';\n    readonly sessionId: SessionId;\n    readonly jobs: readonly SessionJob[];\n} | ({\n    readonly type: \'projection\';\n} & SessionProjectionUpdate);',
   },
   {
     name: 'SessionCreateRequest',
@@ -5307,10 +5311,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionPromptValue',
     declaration: 'export interface SessionPromptValue {\n    readonly accepted: true;\n}',
   },
-  {
-    name: 'SessionQueuedItem',
-    declaration: 'export interface SessionQueuedItem {\n    readonly id: MessageId;\n    readonly placement: \'queued\' | \'steering\' | \'context\';\n    readonly rpcId?: SessionRequestId;\n    readonly message: {\n        readonly id: MessageId;\n        readonly content: readonly JsonValue[];\n    };\n}',
-  },
   {
     name: 'SessionRecord',
     declaration: 'export interface SessionRecord {\n    header: SessionHeader;\n    live: boolean;\n    persisted: boolean;\n}',

+ 0 - 1
packages/test-support/client-runtime/src/fixtures.ts

@@ -65,7 +65,6 @@ export interface SessionFixture {
 export function sessionSnapshot(sessionId: SessionId): SessionSnapshot {
   return {
     sessionId,
-    queue: [],
     pendingSubmissions: [],
     running: false,
     subagent: null,

+ 3 - 0
pnpm-lock.yaml

@@ -2555,6 +2555,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes

+ 1 - 0
tsconfig.host.json

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

部分文件因为文件数量过多而无法显示