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

Revert "feat(session, agent, web): support same-session message editing"

Tianyi Cui 2 өдөр өмнө
parent
commit
e974a655a0
100 өөрчлөгдсөн 206 нэмэгдсэн , 2773 устгасан
  1. 0 3
      .agents/notes/archived/manifest.json
  2. 0 6
      .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml
  3. 2 2
      .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml
  4. 6 6
      .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md
  5. 6 6
      .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md
  6. 2 2
      .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml
  7. 2 2
      .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
  8. 2 2
      .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md
  9. 0 49
      .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md
  10. 0 49
      .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md
  11. 3 3
      .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml
  12. 0 1
      .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md
  13. 0 1
      .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md
  14. 0 2
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  15. 0 2
      apps/web/tests/expected/markdown-images/ui.expected.md
  16. 0 2
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  17. 0 2
      apps/web/tests/expected/math-rendering/ui.expected.md
  18. 0 12
      apps/web/tests/expected/message-edit/replay.override.json
  19. 0 2
      apps/web/tests/expected/reference-composer/order.expected.md
  20. 0 2
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  21. 0 2
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  22. 0 2
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  23. 0 2
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  24. 3 3
      apps/web/tests/message-actions.e2e.ts
  25. 0 90
      apps/web/tests/message-edit.e2e.ts
  26. 0 2
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  27. 0 1
      apps/web/tsconfig.json
  28. 2 2
      docs/architecture.i18n.yaml
  29. 3 4
      docs/architecture.md
  30. 3 4
      docs/architecture.zh.md
  31. 2 2
      docs/config-catalog.i18n.yaml
  32. 2 2
      docs/config-catalog.md
  33. 3 3
      docs/config-catalog.zh.md
  34. 2 2
      docs/event-producer-consumer.i18n.yaml
  35. 21 21
      docs/event-producer-consumer.md
  36. 21 21
      docs/event-producer-consumer.zh.md
  37. 2 2
      docs/persistence-catalog.i18n.yaml
  38. 4 9
      docs/persistence-catalog.md
  39. 4 9
      docs/persistence-catalog.zh.md
  40. 2 2
      docs/subsystems/conversation.i18n.yaml
  41. 1 11
      docs/subsystems/conversation.md
  42. 1 11
      docs/subsystems/conversation.zh.md
  43. 2 2
      docs/subsystems/core.i18n.yaml
  44. 6 33
      docs/subsystems/core.md
  45. 6 33
      docs/subsystems/core.zh.md
  46. 2 2
      docs/subsystems/session-query.i18n.yaml
  47. 3 3
      docs/subsystems/session-query.md
  48. 3 3
      docs/subsystems/session-query.zh.md
  49. 2 2
      docs/subsystems/session-title.i18n.yaml
  50. 2 2
      docs/subsystems/session-title.md
  51. 2 2
      docs/subsystems/session-title.zh.md
  52. 2 2
      docs/subsystems/session.i18n.yaml
  53. 6 62
      docs/subsystems/session.md
  54. 6 62
      docs/subsystems/session.zh.md
  55. 2 2
      docs/tool-catalog.i18n.yaml
  56. 4 2
      docs/tool-catalog.md
  57. 4 2
      docs/tool-catalog.zh.md
  58. 2 2
      packages/api/session-controller/README.i18n.yaml
  59. 4 6
      packages/api/session-controller/README.md
  60. 4 6
      packages/api/session-controller/README.zh.md
  61. 0 14
      packages/api/session-controller/src/client/contract/session.ts
  62. 1 17
      packages/api/session-controller/src/client/contract/snapshot.ts
  63. 1 59
      packages/api/session-controller/src/client/sessions/session.ts
  64. 2 366
      packages/api/session-controller/src/commands.ts
  65. 2 9
      packages/api/session-controller/src/history.ts
  66. 0 14
      packages/api/session-controller/src/index.ts
  67. 0 30
      packages/api/session-controller/src/types.ts
  68. 0 5
      packages/api/session-controller/tests/fake-api.client.ts
  69. 0 723
      packages/api/session-controller/tests/session-edit.host.spec.ts
  70. 0 45
      packages/api/session-controller/tests/session-history-journal.host.spec.ts
  71. 2 59
      packages/api/session-controller/tests/session-pending-submissions.client.spec.ts
  72. 0 7
      packages/api/session-controller/tests/test-remote.ts
  73. 2 2
      packages/client/ui-chat/README.i18n.yaml
  74. 1 11
      packages/client/ui-chat/README.md
  75. 1 11
      packages/client/ui-chat/README.zh.md
  76. 0 4
      packages/client/ui-chat/src/client/apply.ts
  77. 2 3
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  78. 5 75
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  79. 4 13
      packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
  80. 0 88
      packages/client/ui-chat/src/client/chat/MessageItem.module.css
  81. 4 173
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  82. 0 16
      packages/client/ui-chat/src/client/contract/slots.ts
  83. 0 3
      packages/client/ui-chat/src/client/contract/snapshot.ts
  84. 3 16
      packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
  85. 1 2
      packages/client/ui-chat/src/client/conversation-nodes/message.ts
  86. 0 6
      packages/client/ui-chat/src/client/locale.ts
  87. 0 23
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  88. 1 166
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  89. 0 1
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  90. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  91. 1 3
      packages/client/ui-conversation/README.md
  92. 1 3
      packages/client/ui-conversation/README.zh.md
  93. 0 8
      packages/client/ui-conversation/src/client/contract/conversation.ts
  94. 5 27
      packages/client/ui-conversation/src/client/conversation/assembler.ts
  95. 3 24
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  96. 0 85
      packages/client/ui-conversation/src/client/conversation/presentation.ts
  97. 1 1
      packages/client/ui-conversation/src/client/index.ts
  98. 0 65
      packages/client/ui-conversation/tests/conversation-presentation.client.spec.ts
  99. 0 2
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  100. 0 1
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

+ 0 - 3
.agents/notes/archived/manifest.json

@@ -469,9 +469,6 @@
     "simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml": "sha256:ad42430fef4a5db610f8c56acfcda79b40396418694f664a5b0fb093bff1f114",
     "simplification/2026-07-30-sidebar-resize-without-visible-pill.md": "sha256:6f2cfc5121371ec19c7178b31777c223e16bebdc9b63b7654a61cadc4a765b63",
     "simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md": "sha256:ab859eeb12c6a74da3c37d411af52fce37bed3195941570b2f23ccd9a00d55fe",
-    "simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml": "sha256:b1c80890f8ad1fb6a62536f2dfcd44879e66fbce4e36ca471dde6734faee8b4a",
-    "simplification/2026-07-31-drop-user-message-edit-stub.md": "sha256:7c3cc891454822e99b9574a9db296d890261bcb4f86077343e049c757c8f6bdb",
-    "simplification/2026-07-31-drop-user-message-edit-stub.zh.md": "sha256:1725034af33f8f45157a6e244ee51c7582fff62f2ea54aade570746c9f0c1ef1",
     "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111",
     "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7",
     "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438",

+ 0 - 6
.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml

@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md
-2026-07-31-drop-user-message-edit-stub.md: 06b3f7cf1096b1a02ae0efeeec94e1a86a5a4b10
-2026-07-31-drop-user-message-edit-stub.zh.md: cad2981ed0aabd356570291a0039dfed207d0ee2

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.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-07-29-human-transcript-append-origin.md
-2026-07-29-human-transcript-append-origin.md: 8e5f35312546d140234553498c00f1dc2ca175cb
-2026-07-29-human-transcript-append-origin.zh.md: f0402d0f7ad8f1afab8babfde4aadc4902724737
+2026-07-29-human-transcript-append-origin.md: 00f4cf09139bdba4c0fffcdc476b698d098154bc
+2026-07-29-human-transcript-append-origin.zh.md: 631ba71d841033c19a9daa21e646468a0e366966

+ 6 - 6
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md

@@ -12,19 +12,19 @@ Nothing was lost from the log. `Session.snapshotEvents()` still returned every o
 
 ## Decision
 
-Model and human projections are separate. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the default durable source for a transcript; replacement copies stay model-only unless a replacement `user/message` carries the explicit `conversationOp` introduced by [same-session user-message editing](../feature/2026-09-01-same-session-user-message-edit.md). That operation starts a new visible generation and hides its declared raw-event range without deleting it. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`.
+Model and human projections are separate, and the event's own marker decides which one an event belongs to. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the durable source for a transcript; replacement copies stay model-only. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`.
 
 The terminal replays the transcript from append-origin surface events and keeps a shadowed step's tool cards paired through `transcriptToolCallIds`, which reads the append-origin `assistant/message` rather than surface membership. A landed compaction contributes one dim `… earlier context was compacted …` row at its own log position: the marker reports where the model stopped seeing that history instead of erasing it. The framed checkpoint payload never renders, and both paths classify a surface event by the same marker, so a compaction that arrives live and the same log replayed after resume produce the same transcript. Only replay re-derives `tool/call` pairing: a call event carries no marker of its own and inherits membership from the `assistant/message` that advertised it, which the live listener has necessarily just rendered.
 
-A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactionEngine` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Replacement events without `conversationOp` remain silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation.
+A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactionEngine` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Other replacements are silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation.
 
-`session.history` counts append-origin messages and explicit conversation replacements toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compaction/summary` event stays on the page of the replacement that cites it and an edited generation can retain its hidden raw interval for lossless replay.
+`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compaction/summary` event stays on the page of the replacement that cites it.
 
-Compaction still changes no user-facing replacement state. Same-session Edit adds its own persisted `conversationOp` and RPC operation without changing compaction transactions.
+No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
 
 ## Deferred
 
-The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the default append-origin transcript in log order, applies explicit edited-generation ranges before assembly, and renders a compaction marker component. It also closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page.
+The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page.
 
 The terminal's [archived live compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction.
 
@@ -38,7 +38,7 @@ The terminal's [archived live compaction progress decision](../../archived/featu
 
 **Derive the marker from the `compaction/*` bracket instead of the checkpoint.** Rejected for the transcript: the bracket is a pair of time-point markers around an operation, while the transcript needs the position where the surface actually changed. The bracket is the right source for progress and duration, which this change does not render.
 
-**Classify every replacement through the model-surface fold, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: compaction transcript membership remains a per-event marker question. Same-session Edit instead carries a distinct `conversationOp` whose whole-log fold answers the different question of which visible generation is current.
+**Classify events by re-folding the log, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: a fold answers a whole-log question, while a projection asks a per-event one that the event's own marker already answers in constant time.
 
 ## Consequences
 

+ 6 - 6
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md

@@ -12,19 +12,19 @@ Status: implemented
 
 ## 决策
 
-模型投影与人类投影是分开的。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源事件是 transcript 的默认持久来源;除非替换型 `user/message` 携带[同会话用户消息编辑](../feature/2026-09-01-same-session-user-message-edit.zh.md)引入的显式 `conversationOp`,替换副本都只供模型使用。该操作开启一代新的可见对话,并隐藏其声明的原始事件区间而不删除它。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。
+模型投影与人类投影是分开的,而事件属于哪一种由事件自身的标记决定。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源的事件是 transcript 的持久来源,替换副本仅供模型使用。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。
 
 终端从追加来源的 surface 事件回放 transcript,并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对:该函数读取追加来源的 `assistant/message`,而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`:这行标记报告模型从何处起不再看到那段历史,而不是把它抹掉。带框的检查点载荷从不渲染,且两条路径都按同一个标记对 surface 事件分类,因此实时到达的压缩与恢复后回放同一份日志会产生相同的 transcript。只有回放会重新推导 `tool/call` 的配对关系:调用事件自身不携带标记,其归属继承自公布它的 `assistant/message`,而实时监听器必然刚刚渲染过后者。
 
-检查点通过压缩 seam 自身的约定来识别——`isCompactCheckpointSource`,即 `CompactionEngine` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。没有 `conversationOp` 的替换事件保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。
+检查点通过压缩 seam 自身的约定来识别——`isCompactCheckpointSource`,即 `CompactionEngine` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。其他替换保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。
 
-`session.history` 把追加来源消息与显式对话替换计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compaction/summary` 事件会与引用它的替换留在同一页,编辑后的代次也可以保留其隐藏原始区间用于无损回放
+`session.history` 把追加来源消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compaction/summary` 事件会与引用它的替换留在同一页。
 
-压缩仍不改变用户可见替换状态。同会话 Edit 增加独立的持久 `conversationOp` 与 RPC 操作,不改变压缩事务
+持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化,也不需要迁移
 
 ## 延后事项
 
-浏览器客户端在[Web transcript 投影笔记](2026-07-30-web-transcript-log-ordered-projection.zh.md)中单独修复:它按日志顺序投影默认的追加来源 transcript,在组装前应用显式编辑代次区间,并渲染一个压缩标记组件。它同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。
+浏览器客户端在[Web transcript 投影笔记](2026-07-30-web-transcript-log-ordered-projection.zh.md)中单独修复:它按日志顺序投影同一份追加来源 transcript 并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。
 
 终端的[已归档实时压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件。
 
@@ -38,7 +38,7 @@ Status: implemented
 
 **用 `compaction/*` 括号而不是检查点来推导标记。** 就 transcript 而言被否决:括号是围绕一次操作的一对时间点标记,而 transcript 需要的是 surface 真正发生变化的位置。括号适合作为进度与耗时的来源,而本次变更并不渲染这些。
 
-**像 `session-query` 为搜索所做的那样,通过模型 surface 折叠分类每个替换(`current`/`shadowed`/`log-only`)。** 被否决:压缩的 transcript 成员关系仍是逐事件标记问题。同会话 Edit 改用独立的 `conversationOp`;其整日志折叠回答的是另一项问题——哪一代可见对话是当前代次
+**像 `session-query` 为搜索所做的那样重新折叠日志来分类事件(`current`/`shadowed`/`log-only`)。** 被否决:折叠回答的是整份日志的问题,而投影问的是逐事件的问题,事件自身的标记已能以常数时间给出答案
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
-2026-07-30-web-transcript-log-ordered-projection.md: 77873cd9df32d1c7970731a8158284d1b4869a82
-2026-07-30-web-transcript-log-ordered-projection.zh.md: 7ccc2733dab18ccb326558bbb939c152b21d7f2b
+2026-07-30-web-transcript-log-ordered-projection.md: ed6fa4df3ac25bf6fe2947e6fb7bb7cbde6ac00a
+2026-07-30-web-transcript-log-ordered-projection.zh.md: cf8dc35099f416356310081f758d0b89fe8282b9

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md

@@ -12,7 +12,7 @@ Surface order made two further problems structural. It is not seq-ascending afte
 
 ## Decision
 
-`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the retained raw window in log order: append-origin surface events (`isAppendSurfaceEvent`) at their own log positions, one `CompactionSummaryNode` marker per landed compaction checkpoint, and the explicit replacement `user/message` from [same-session user-message editing](../feature/2026-09-01-same-session-user-message-edit.md). Before assembly, that feature's `conversationOp` fold removes the prior visible generation it names. A landed compaction therefore keeps the conversation it shadowed on the model side, while an Edit deliberately replaces its old user-facing range. Other model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two remain separate on both frontends.
+`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the raw window in log order: every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends.
 
 Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged.
 
@@ -69,7 +69,7 @@ Compaction no longer erases web history; a session compacted several times shows
 
 `ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor.
 
-The incremental performance contract remains: one ordinary append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not recomputed — and unchanged nodes keep their object identity. A `conversationOp` append deliberately rebuilds the loaded window once so every target publishes one coherent generation. The raw window still grows with session length even when Edit hides older generations, which preserves lossless paging at the cost of scanning retained events during that rebuild.
+The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves.
 
 The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface.
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md

@@ -12,7 +12,7 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它
 
 ## Decision
 
-`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影保留的原始窗口:append 来源的 surface 事件(`isAppendSurfaceEvent`)落在各自日志位置上,每次落地的压缩检查点对应一个 `CompactionSummaryNode` 标记,[同会话用户消息编辑](../feature/2026-09-01-same-session-user-message-edit.zh.md)则提供显式替换 `user/message`。组装前,该功能的 `conversationOp` 折叠会移除它所标识的先前可见代次。因此,一次落地的压缩会保留它在模型侧遮蔽的对话,而一次 Edit 会有意替换其旧用户可见区间。其他仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者在两个前端上继续分离。
+`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影原始窗口:每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话,标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者现在在两个前端上都已分离。
 
 节点顺序天然按 seq 单调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折叠成 `CommandNode`,按 seq 插入一个本已单调的数组——无锚点,无重排。`Session` 保留被打断的冻结节点的归属,用一次普通排序按其分数 seq 归并,而这现在恰好就是流顺序。检查点所引被遮蔽范围落在窗口之外的窗口没有范围需要解析,因此标记正常渲染且不打印任何日志。
 
@@ -69,7 +69,7 @@ const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact'
 
 `ConversationNode` 增加第八个分支,因此每个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无单元格”分支,使标记不贡献单元格但仍推进耗时游标。
 
-增量性能约定保持不变:一次普通 append 物化一个 node;不改变 node 的 event 保留旧数组引用——因此 chunk 风暴不产生任何开销,也不会重新计算 `nodes()`——未变化的 node 仍保持对象 identity。一次 `conversationOp` append 会有意重建一次已加载窗口,使每个 target 只发布一份完整代次。即使 Edit 隐藏较旧代次,原始窗口仍随 Session 长度增长;这保留了无损分页,但替换重建时需要扫描保留事件
+性能约定未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模
 
 Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。
 

+ 0 - 49
.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md

@@ -1,49 +0,0 @@
-# Agent Note: Same-session user-message editing
-
-Status: implemented
-
-English | [中文](2026-09-01-same-session-user-message-edit.zh.md)
-
-## Problem
-
-Correcting a sent message required creating a fork even when the user wanted to keep working in the same Session. The [earlier removal of the unbacked Edit control](../../archived/simplification/2026-07-31-drop-user-message-edit-stub.md) correctly withheld that affordance until a Host operation existed. Reusing model-surface replacement alone was insufficient: compaction intentionally removes old messages only from model context, while Edit must also replace the visible Chat, Trajectory, turn-navigation, title-input, and search generation without deleting audit data. A running turn, queued prompts, attachments, and session-reference context also make a same-session retry more than a UI text mutation.
-
-## Decision
-
-The ordinary-Session `session.edit` operation accepts only the latest human `user/message`, and that message must open a turn and remain in the current model surface. The request identifies the target event and carries the latest human-message seq observed when the editor opened. The Host validates both before any interruption and repeats validation while it owns idle maintenance; a later human message makes the request stale. Earlier messages, direct subagent Sessions, steering messages, compacted messages, and messages without editable text are rejected.
-
-If the Agent is running, Edit cancels the active turn with `keepInbox: true` and reserves maintenance synchronously at the idle transition. It inserts the replacement at the front of `next-turn`, so the edited rerun precedes existing Queue work without deleting it. The durable inbox admission carries the replacement's `SurfaceIntent` and any retained session-reference messages; AgentLoop expands those companions immediately after the edited prompt and follows the primary message by `MessageId` through `agent/pre-step` rewrites. The pre-step payload exposes claimed intents, and automatic compaction defers while a preplanned surface replacement is pending so its coordinates remain valid through commit. A restart after admission therefore preserves both replacement semantics and reference ordering.
-
-The replacement message keeps the target's non-text content, uses the submitted text and a new timestamp, and runs with the Session's current model selection. An image-bearing edit validates that model before interrupting active work. Existing session-reference recall messages are copied rather than regenerated, so the rerun uses the snapshot the original prompt cited. Edit does not rewind files, processes, background work, subagents, or any other external side effect.
-
-The committed replacement `user/message` carries two independent operations. Its `surfaceOp` replaces the current model-message suffix from the edited turn onward, and `sourceEventSeqs` names every removed surface node. Its `conversationOp` hides the inclusive raw-event range from that turn's `turn/start` through the pre-admission log tail from current user-facing projections. All old events remain in the append-only log.
-
-`ui-conversation` folds `conversationOp` ranges before every target assembles nodes and rebuilds the loaded window atomically when a replacement arrives. Chat therefore never publishes a mixed old/new generation. The turn outline removes hidden turns. A replacement cancels pending or active automatic title work without scheduling a new revision; a later ordinary title revision reads only the current conversation generation. Session Query classifies every searchable event in a hidden edit range as `shadowed`; model-facing search defaults to `current` plus `log-only` and does not expose `shadowed`, while exact reads and traces remain available for diagnostics. Lossless Session export and whole-log statistics retain both generations.
-
-Chat exposes Edit only on that latest eligible message. Editing uses a full Chat-width, composer-style input card with input-surface fill, border, elevation, and an internal cancel/save row. The textarea starts at 80px, grows with its content to 240px, and then scrolls internally. Enter submits, Shift+Enter inserts a newline, Escape cancels, and the normal composer remains enabled. Starting another submission closes the editor. Submission immediately replaces the selected suffix with a local echo; failure restores the durable view, while success hands off to the replacement event. The result has no edited badge or Undo action, and the existing Session title remains unchanged.
-
-The shipped scope is the Edit part of [issue #2351](https://github.com/deepseek-harness/deepseek-harness/issues/2351). Fork and Rewind remain separate behavior.
-
-## Alternatives considered
-
-**Always fork before rerunning.** Rejected because it changes Session identity and leaves the user to choose between correction and branching. Fork remains useful when both histories should stay independently navigable.
-
-**Mutate or delete the original events.** Rejected because Session persistence, replay, diagnostics, and lossless export depend on an append-only log. A replacement event records the new generation while retaining the prior facts.
-
-**Treat every `SurfaceOp` replacement as a human transcript replacement.** Rejected because compaction and result pruning intentionally change model context without erasing what the user already saw. `conversationOp` is explicit and independent.
-
-**Reuse the pending Queue editor.** Rejected because a queued message has not entered model history, while a historical edit must replace consumed context, interrupt current work safely, and rerun.
-
-**Clear Queue or apply it in the edited request.** Rejected because Queue entries remain user-owned future turns. The edited turn runs first and leaves a window in which the user may still remove queued work.
-
-**Provide Undo together with Edit.** Rejected because undoing after new model output requires another explicitly defined generation replacement and conflict policy. The retained raw events preserve the data without advertising an unsupported reversal.
-
-## Consequences
-
-One edit adds a new turn plus durable inbox and replacement metadata; storage, lossless export, and whole-log statistics grow with every generation. Current conversation views and default model search omit replaced generations, while diagnostic reads can still inspect them.
-
-Conversation-range lookup is logarithmic in the number of merged edit ranges for each event. The SQLite provider already rebuilds documents for a changed live Session; Edit adds classification work but no second text index or delete/reinsert protocol for historical rows.
-
-The unchanged prefix before the first replaced model message remains eligible for provider KV-cache reuse. The edited suffix starts a new request series and incurs normal request and response tokens.
-
-Unit coverage pins range validation and folding, durable inbox replay, pre-step intent retention, Queue priority, interruption, attachment and reference preservation, title and outline behavior, search classification, and optimistic UI handoff. The Web replay e2e pins edit, rerun, hidden old output, and refresh reconstruction through the real Host and browser.

+ 0 - 49
.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md

@@ -1,49 +0,0 @@
-# Agent Note: 同会话用户消息编辑
-
-Status: implemented
-
-[English](2026-09-01-same-session-user-message-edit.md) | 中文
-
-## 问题
-
-即使用户希望留在同一个 Session 中继续工作,修正已发送消息也必须创建 fork。[此前移除无后端 Edit 控件的决策](../../archived/simplification/2026-07-31-drop-user-message-edit-stub.md)正确地在 Host 操作存在前隐藏了这项可供性。仅复用模型 surface 替换并不够:压缩有意只从模型上下文移除旧消息,而 Edit 还必须替换 Chat、Trajectory、轮次导航、标题输入和搜索中的可见对话代次,同时不能删除审计数据。运行中的轮次、已排队提示词、附件和会话引用上下文也使同会话重跑不只是一次 UI 文本修改。
-
-## 决策
-
-普通 Session 的 `session.edit` 操作只接受最新的人工 `user/message`,且该消息必须用于开启轮次并仍位于当前模型 surface。请求标识目标事件,并携带编辑器打开时观测到的最近人工消息 seq。Host 会在任何中断前进行校验,并在持有 idle maintenance 时再次校验;后续人工消息会使请求变为 stale。更早的消息、direct subagent Session、steering 消息、已压缩消息和没有可编辑文本的消息都会被拒绝。
-
-如果 Agent 正在运行,Edit 会用 `keepInbox: true` 取消活跃轮次,并在其进入 idle 时同步取得 maintenance。它把替换消息插到 `next-turn` 前端,因此编辑重跑先于已有 Queue 工作执行,但不会删除 Queue。持久 inbox 准入会携带替换消息的 `SurfaceIntent` 与所有保留的会话引用消息;AgentLoop 在编辑提示词之后立即展开这些配套消息,并通过 `MessageId` 让非默认放置方式穿过 `agent/pre-step` 重写。pre-step payload 会公开已领取的 intent,主动压缩在预定 surface 替换待提交时推迟,因此替换坐标在提交前保持有效。即使准入后发生重启,替换语义和引用顺序仍会保留。
-
-替换消息保留目标消息的非文本内容,使用提交的新文本和新时间戳,并采用 Session 当前的模型选择。带图片的编辑会在中断活跃工作前验证该模型。已有会话引用 recall 消息会被复制而不重新生成,因此重跑使用原提示词引用的快照。Edit 不会回退文件、进程、后台任务、subagent 或任何其他外部副作用。
-
-已提交的替换 `user/message` 携带两个独立操作。其 `surfaceOp` 替换从编辑轮次开始的当前模型消息后缀,`sourceEventSeqs` 列出每个被移除的 surface 节点;其 `conversationOp` 从当前用户可见投影中隐藏自该轮 `turn/start` 到准入前日志末尾的原始事件闭区间。所有旧事件仍保留在仅追加日志中。
-
-`ui-conversation` 在每个 target 组装 node 前折叠 `conversationOp` 区间,并在替换到达时原子重建已加载窗口,因此 Chat 不会发布新旧代次混合的中间状态。轮次大纲会移除被隐藏的轮次。替换消息会取消待执行或正在运行的自动标题工作,但不会安排新的 revision;后续普通标题 revision 只读取当前对话代次。Session Query 把编辑隐藏区间内每个可搜索事件分类为 `shadowed`;面向模型的搜索默认只查询 `current` 与 `log-only`,且不公开 `shadowed`,精确读取与追踪仍可用于诊断。无损 Session 导出与全日志统计保留两代内容。
-
-Chat 只在该最新且符合条件的消息上公开「编辑消息」。编辑使用铺满 Chat 内容列的 composer 风格输入卡片,带输入表面背景、边框、阴影和卡片内部的取消/保存操作行。文本框初始高度为 80px,随内容增长至 240px 后改为内部滚动。Enter 提交,Shift+Enter 插入换行,Escape 取消,普通 composer 保持可用。开始另一条提交会关闭编辑器。提交会立刻用本地回显替换选中后缀;失败恢复持久视图,成功则交接给替换事件。结果不显示已编辑标记,也没有 Undo,已有 Session 标题保持不变。
-
-已交付范围是 [issue #2351](https://github.com/deepseek-harness/deepseek-harness/issues/2351) 的 Edit 部分。Fork 与 Rewind 仍是独立行为。
-
-## 曾考虑的替代方案
-
-**每次重跑前都创建 fork。** 被否决,因为它会改变 Session 身份,并迫使用户在修正与分支之间做选择。需要让两份历史都可独立导航时,fork 仍然有用。
-
-**修改或删除原事件。** 被否决,因为 Session 持久化、回放、诊断与无损导出都依赖仅追加日志。替换事件记录新代次,同时保留先前事实。
-
-**把每个 `SurfaceOp` 替换都视为人类 transcript 替换。** 被否决,因为压缩与结果裁剪有意改变模型上下文,却不抹掉用户已经看到的内容。`conversationOp` 是显式且独立的机制。
-
-**复用待处理 Queue 编辑器。** 被否决,因为已排队消息尚未进入模型历史,而历史编辑必须替换已消费上下文、安全中断当前工作并重新运行。
-
-**清空 Queue,或把它应用到编辑请求中。** 被否决,因为 Queue 条目仍是用户拥有的未来轮次。编辑轮次先运行,并留出用户继续删除排队工作的时间窗口。
-
-**让 Undo 与 Edit 同时提供。** 被否决,因为在新模型输出出现后撤销需要再定义一套显式代次替换和冲突策略。保留的原始事件保存了数据,但不会宣告尚不受支持的反转操作。
-
-## 后果
-
-每次编辑都会增加一个新轮次,以及持久 inbox 与替换元数据;存储、无损导出和全日志统计都会随每一代内容增长。当前对话视图与默认模型搜索省略被替换代次,诊断读取仍可检查它们。
-
-对每个事件进行对话区间成员判断的复杂度,与合并后编辑区间数量呈对数关系。SQLite 提供方本就会为变化后的实时 Session 重建文档;Edit 增加分类工作,但不引入第二份文本索引,也不需要删除并重插历史行。
-
-首条被替换模型消息之前未变化的前缀仍可复用提供方 KV Cache。编辑后的后缀会开启新的请求序列,并正常产生请求与响应 token。
-
-单元测试固定区间校验与折叠、持久 inbox 回放、pre-step intent 保留、Queue 优先级、中断、附件与引用保留、标题与大纲行为、搜索分类和乐观 UI 交接。Web replay e2e 通过真实 Host 与浏览器固定编辑、重跑、隐藏旧输出和刷新重建。

+ 3 - 3
.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.i18n.yaml → .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md
-2026-09-01-same-session-user-message-edit.md: 15f6bb36606037ddc04126195fcfeb645a61a3b3
-2026-09-01-same-session-user-message-edit.zh.md: 16eb5e156016a27d075bb1eccbb0358273d0f758
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md
+2026-07-31-drop-user-message-edit-stub.md: 5a34be1dd285f4aced9e6cfb2e324b20ec734bed
+2026-07-31-drop-user-message-edit-stub.zh.md: 288f2bda5d41cf0a331f69f134c8cf1e61a801ef

+ 0 - 1
.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md → .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md

@@ -1,7 +1,6 @@
 # Agent Note: Drop the user-message edit stub
 
 Status: implemented
-Archived: 2026-09-01
 
 English | [中文](2026-07-31-drop-user-message-edit-stub.zh.md)
 

+ 0 - 1
.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md → .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md

@@ -1,7 +1,6 @@
 # Agent Note: 移除 user 消息的编辑存根
 
 Status: implemented
-Archived: 2026-09-01
 
 [English](2026-07-31-drop-user-message-edit-stub.md) | 中文
 

+ 0 - 2
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -10,8 +10,6 @@
 - text: Render adjacent CJK strong emphasis. {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - heading "CJK strong emphasis" [level=2]
 - paragraph:
   - strong: 注意:

+ 0 - 2
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -10,8 +10,6 @@
 - text: Show the Markdown image policy. {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - heading "Markdown images" [level=2]
 - paragraph:
   - img "Remote test image"

+ 0 - 2
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -10,8 +10,6 @@
 - text: Show the local preview URL. {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - heading "Inline code links" [level=2]
 - paragraph:
   - text: "Preview:"

+ 0 - 2
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -10,8 +10,6 @@
 - text: Render this mathematical proof. {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - heading "Math rendering" [level=2]
 - paragraph:
   - text: Inline dollar

+ 0 - 12
apps/web/tests/expected/message-edit/replay.override.json

@@ -1,12 +0,0 @@
-[
-  {
-    "kind": "chunks",
-    "chunks": [
-      { "type": "block-start", "index": 0, "blockType": "text" },
-      { "type": "text-delta", "index": 0, "text": "EDITED_OK" },
-      { "type": "block-end", "index": 0, "block": { "type": "text", "text": "EDITED_OK" } },
-      { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2, "cacheReadTokens": 0, "reasoningTokens": 0 } },
-      { "type": "finish", "reason": { "kind": "stop" } }
-    ]
-  }
-]

+ 0 - 2
apps/web/tests/expected/reference-composer/order.expected.md

@@ -10,8 +10,6 @@
 - text: Research notes what changed? Referenced session · Research notes {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - button "Session recall Research notes":
   - img
   - text: Session recall Research notes

+ 0 - 2
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -16,8 +16,6 @@
 - text: /user-invoke-demo and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - button "Thought for a while" [expanded]:
   - text: Thought for a while
   - img

+ 0 - 2
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -16,8 +16,6 @@
 - text: /user-invoke-demo and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - button "Thought for a while":
   - text: Thought for a while
   - img

+ 0 - 2
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -444,8 +444,6 @@
 - text: 7/25 {{clock}} m28 7/25 {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - paragraph: r28
 - button "Copy":
   - img

+ 0 - 2
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -16,8 +16,6 @@
 - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - button "Context injection @deepseek-ai/dsh-system-prompt":
   - img
   - img

+ 3 - 3
apps/web/tests/message-actions.e2e.ts

@@ -114,8 +114,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
 
     // Focus-reveal the footers (hover:hover keeps them opacity-hidden until
-    // hover/focus-within). Branch renders only under assistant answers, while
-    // only the latest current turn-opening user message exposes Edit.
+    // hover/focus-within). Branch renders only under assistant answers — user
+    // bubbles carry none — and only a completed transcript tail enables it.
     const copyButtons = page.getByRole('button', { name: 'Copy' })
     await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
     await copyButtons.first().focus()
@@ -128,7 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
     await branchButtons.first().focus()
     await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
       .toBe('Available only on the last message of a completed turn')
-    await expect.poll(() => page.getByRole('button', { name: 'Edit message' }).count(), { timeout: 5_000 }).toBe(1)
+    await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
   }, 60_000)
 
   it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {

+ 0 - 90
apps/web/tests/message-edit.e2e.ts

@@ -1,90 +0,0 @@
-// Web e2e: same-session user-message Edit replaces the selected turn and
-// reruns directly without creating a fork or exposing the old generation.
-import { readFile } from 'node:fs/promises'
-import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
-import type { Browser, Page } from 'playwright'
-import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
-import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
-  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
-} from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
-
-const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-edit', import.meta.url))
-const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url))
-const OVERRIDE = fileURLToPath(new URL('./expected/message-edit/replay.override.json', import.meta.url))
-const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
-const MODE = webSnapshotMode()
-const SESSION_ID = 'message-edit-web-e2e'
-const ORIGINAL = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
-const EDITED = 'Use the existing context and reply with exactly EDITED_OK.'
-
-describe('web e2e: edit a historical user message in the same Session', () => {
-  let scaffold: WebScaffold
-  let browser: Browser
-  let page: Page
-  let tripwire: ReturnType<typeof watchConsole>
-
-  beforeAll(async () => {
-    scaffold = await launchWebScaffold({
-      replayFixture: SEED,
-      replayOverride: OVERRIDE,
-      compareReplaySession: false,
-    })
-    await seedSession(scaffold, await readFile(SEED, 'utf8'), SESSION_ID)
-    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.skipIf(MODE === 'record')('replaces the old generation, reruns, and survives refresh', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-message-edit'))
-    await page.locator('[role="treeitem"]').first().click()
-    const sessionRow = page.locator('[role="treeitem"]').nth(1)
-    await sessionRow.click()
-    await page.getByText(ORIGINAL, { exact: true }).waitFor({ timeout: 15_000 })
-
-    await page.getByRole('button', { name: 'Edit message' }).click()
-    const editor = page.getByRole('textbox', { name: 'Edit message' })
-    await editor.fill(EDITED)
-    const settled = scaffold.whenTurnSettled(30_000)
-    await editor.press('Enter')
-    await settled
-
-    await page.getByText(EDITED, { exact: true }).waitFor({ timeout: 15_000 })
-    await page.getByText('EDITED_OK', { exact: true }).waitFor({ timeout: 15_000 })
-    expect(await page.getByText(ORIGINAL, { exact: true }).count()).toBe(0)
-    expect(await page.getByText('DONE', { exact: true }).count()).toBe(0)
-    const live = scaffold.ctx.sessions.get(SessionId(SESSION_ID))
-    const replacement = live?.snapshotEvents().findLast((event): event is SessionEvent<'user/message'> =>
-      event.type === 'user/message' && event.conversationOp !== undefined)
-    expect(replacement).toMatchObject({
-      data: { content: [{ type: 'text', text: EDITED }] },
-      surfaceOp: { op: 'replace' },
-      conversationOp: { op: 'replace' },
-    })
-
-    await page.reload({ waitUntil: 'load' })
-    await page.getByText(EDITED, { exact: true }).waitFor({ timeout: 15_000 })
-    await page.getByText('EDITED_OK', { exact: true }).waitFor({ timeout: 15_000 })
-    expect(await page.getByText(ORIGINAL, { exact: true }).count()).toBe(0)
-    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
-    expect(tripwire.pageErrors).toEqual([])
-    expect(tripwire.warnings).toEqual([])
-  }, 90_000)
-
-  it.skipIf(MODE === 'record')('keeps the expected artifact inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
-  })
-})

+ 0 - 2
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -16,8 +16,6 @@
 - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
 - button "Copy":
   - img
-- button "Edit message":
-  - img
 - button "Context injection @deepseek-ai/dsh-system-prompt":
   - img
   - img

+ 0 - 1
apps/web/tsconfig.json

@@ -63,7 +63,6 @@
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",
     "tests/message-actions.e2e.ts",
-    "tests/message-edit.e2e.ts",
     "tests/message-feedback.e2e.ts",
     "tests/message-feedback-layout.e2e.ts",
     "tests/markdown-images.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: cf337b12c3ec4050e85618931f8e26a5d8bcc0ec
-architecture.zh.md: 3bac2917e22722bbcda7820d2d4673430dfe4e45
+architecture.md: 902fd7b53fe7493127da68f9a8f381a33b8edc18
+architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4

+ 3 - 4
docs/architecture.md

@@ -94,15 +94,15 @@ turn/end
 
 `turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
 
-Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. An inbox admission may retain non-default Session placement and ordered same-step companion messages with its primary `MessageId`, so replay and pre-step rewriting preserve the complete admitted batch.
+Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 
-`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. Non-default Session placement follows a surviving message by identity, and the payload exposes those intents so a listener that mutates the Session surface can defer while a preplanned replacement is pending. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
+`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
 
 Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
 
 ## Session log
 
-The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. `SurfaceOp` owns model-history replacement; an explicit `conversationOp` on a replacement `user/message` independently hides one raw-event range from current user-facing projections while the append-only log remains lossless. Fork, resume, transcripts, telemetry, and persistence all derive from this stream.
+The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream.
 
 **Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log.
 
@@ -135,7 +135,6 @@ New behavior attaches to a documented extension point. Changing the loop itself
 | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` stops a turn |
 | Add model-facing context | call `agent.inject()`; it lands in the next admitted request |
 | Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
-| Edit the latest human message | call the ordinary-Session `session.edit` RPC; when it remains the current turn-opening prompt, the RPC appends a replacement generation in the same Session |
 | Add a Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer |
 | Add durable session state | extend `SessionEventMap`; render and replay from the log |
 | Generate session titles | register the sole `ctx.sessionTitle` provider |

+ 3 - 4
docs/architecture.zh.md

@@ -98,15 +98,15 @@ turn/end
 
 `turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
 
-输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。一次 inbox 准入可以随主消息的 `MessageId` 保留非默认 Session 放置方式和有序的同一步骤配套消息,因此回放与 pre-step 重写都能保留完整准入批次。
+输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 
-`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。非默认 Session 放置方式按身份跟随仍被保留的消息,payload 也会公开这些 intent,使会修改 Session surface 的监听器能在预定替换待提交时推迟操作。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
+`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
 
 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
 
 ## 会话日志
 
-会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。`SurfaceOp` 负责替换模型历史;替换型 `user/message` 上的显式 `conversationOp` 独立地从当前用户可见投影隐藏一段原始事件区间,而仅追加日志保持无损。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流。
+会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流。
 
 **模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。
 
@@ -139,7 +139,6 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统
 | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 会停止轮次 |
 | 添加模型可见上下文 | 调用 `agent.inject()`;它会落到下一次获准的请求中 |
 | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 |
-| 编辑最新的人工消息 | 调用普通 Session 的 `session.edit` RPC;它仍是当前轮次开场提示词时,RPC 会在同一 Session 中追加替换代次 |
 | 添加 Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer |
 | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 |
 | 生成会话标题 | 注册唯一的 `ctx.sessionTitle` 提供方 |

+ 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: ced73f7a75dac2fb0c4daefe65a30c59346578a6
-config-catalog.zh.md: 9aa539d8fab02b56fd2a54d22e9233bbbbb74f9f
+config-catalog.md: 6a0f7f23885cd85ccb1ff20e9322f133767aed4a
+config-catalog.zh.md: cae9fa49a6986130c8b7261cf41e04b4d6b4e0e1

+ 2 - 2
docs/config-catalog.md

@@ -217,7 +217,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts)
+Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts)
 
 <a id="deepseek-aidsh-api-settings-controller"></a>
 
@@ -2012,7 +2012,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/session/session-title/src/index.ts:58`](../packages/session/session-title/src/index.ts)
+Source: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts)
 
 <a id="deepseek-aidsh-session-title-all-prompts-llm"></a>
 

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

@@ -219,7 +219,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts)
+来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts)
 
 <a id="deepseek-aidsh-api-settings-controller"></a>
 
@@ -2000,7 +2000,7 @@ export enum SessionTelemetryMode {
 
 ## `@deepseek-ai/dsh-session-title`
 
-需要:`sessions` · `sessionProjections`
+需要:`sessions`
 
 ```ts config-catalog
 /** Required deterministic fallback and accepted-title limits. */
@@ -2014,7 +2014,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/session/session-title/src/index.ts:58`](../packages/session/session-title/src/index.ts)
+来源:[`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts)
 
 <a id="deepseek-aidsh-session-title-all-prompts-llm"></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: 57e15aae28a0eeb0cf7203fcfb2d054413a96624
-event-producer-consumer.zh.md: c0b3328e51467b23bc8945fa5f71f9fc607b2f67
+event-producer-consumer.md: d04a37ee71756619b36de192011a0053e5ceccb8
+event-producer-consumer.zh.md: 226c368517d989cba2eeac515ee2c544151e8005

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

@@ -9,23 +9,23 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:177`](../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) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:310`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:235`](../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:196`](../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:298`](../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:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -45,10 +45,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |

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

@@ -11,23 +11,23 @@
 | --- | --- | --- | --- | --- |
 | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:177`](../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) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:310`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:235`](../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:196`](../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:298`](../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:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -47,10 +47,10 @@
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |

+ 2 - 2
docs/persistence-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/persistence-catalog.md
-persistence-catalog.md: b5ad3550d28b87dc76bdb7c71bf83c24f9843f05
-persistence-catalog.zh.md: a2823bd087165ac3853b0e9a7680daf6f6776880
+persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c
+persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a

+ 4 - 9
docs/persistence-catalog.md

@@ -48,8 +48,7 @@ export type SurfaceOp =
  * A proper discriminated union over `type` (not independent `type`/`data`
  * unions), so `switch (event.type)` narrows `event.data` without casts.
  *
- * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp}
- * fields are conditional:
+ * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
  * they only exist on {@link SurfaceEventType} variants (`user/message`,
  * `assistant/message`, `tool/result`).
  * Non-surface events (boundary markers, chunks, usage, errors) never carry
@@ -87,13 +86,11 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
     sourceEventSeqs?: SessionSeq[]
     /** How this event entered the surface; absent for non-surface events. */
     surfaceOp?: SurfaceOp
-    /** Raw event range this message replaces in current conversation projections. */
-    conversationOp?: ConversationOp
   } : object)
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:449`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -114,13 +111,11 @@ Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src
   start: number
   removedCount?: number
   inserted: UserMessage[]
-  /** Optional per-message placement and same-step companions. */
-  admissions?: InboxAdmission[]
   outcome?: 'canceled'
 }
 ```
 
-Source: [`packages/core/agent/src/types.ts:71`](../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts)
 
 ### `agent-preset/*`
 
@@ -678,7 +673,7 @@ Source: [`packages/core/session/src/types.ts:364`](../packages/core/session/src/
 
 Types: [SessionTitleEventData](subsystems/session-title.md)
 
-Source: [`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts)
+Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts)
 
 <a id="sessiontitle-llm-request--log-only"></a>
 

+ 4 - 9
docs/persistence-catalog.zh.md

@@ -50,8 +50,7 @@ export type SurfaceOp =
  * A proper discriminated union over `type` (not independent `type`/`data`
  * unions), so `switch (event.type)` narrows `event.data` without casts.
  *
- * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp}
- * fields are conditional:
+ * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
  * they only exist on {@link SurfaceEventType} variants (`user/message`,
  * `assistant/message`, `tool/result`).
  * Non-surface events (boundary markers, chunks, usage, errors) never carry
@@ -89,13 +88,11 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
     sourceEventSeqs?: SessionSeq[]
     /** How this event entered the surface; absent for non-surface events. */
     surfaceOp?: SurfaceOp
-    /** Raw event range this message replaces in current conversation projections. */
-    conversationOp?: ConversationOp
   } : object)
 }[T]
 ```
 
-来源:[`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:449`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts)
 
 ## 事件
 
@@ -116,13 +113,11 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
   start: number
   removedCount?: number
   inserted: UserMessage[]
-  /** Optional per-message placement and same-step companions. */
-  admissions?: InboxAdmission[]
   outcome?: 'canceled'
 }
 ```
 
-来源:[`packages/core/agent/src/types.ts:71`](../packages/core/agent/src/types.ts)
+来源:[`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts)
 
 ### `agent-preset/*`
 
@@ -680,7 +675,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[SessionTitleEventData](subsystems/session-title.zh.md)
 
-来源:[`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts)
+来源:[`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts)
 
 <a id="sessiontitle-llm-request--log-only"></a>
 

+ 2 - 2
docs/subsystems/conversation.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/conversation.md
-conversation.md: 415bc46078c61710a7111e3f1951beda736d39e9
-conversation.zh.md: a178b49ea2d55c9f11cc0002da1fea19f8b2d748
+conversation.md: df1476537b95690ae2055f367e8586653b99a9a9
+conversation.zh.md: 784f52975cbb829d8914ef630aa1041693e1de62

+ 1 - 11
docs/subsystems/conversation.md

@@ -8,17 +8,7 @@ This page defines the data model and the extension path for a business-owned Con
 
 ## Data model and ownership
 
-The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. Before assembly, `ConversationPresentationState` folds explicit `conversationOp` replacements, removes entries in hidden raw-event ranges from every current target, and computes loaded current-surface membership for target-owned actions. A replacement append rebuilds the loaded window atomically, so no target publishes an intermediate mix of old and new generations. Ordinary surface replacements without `conversationOp`, including compaction checkpoints, retain their established human-transcript behavior.
-
-`ui-conversation` passes each retained entry to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
-
-```ts type-equiv
-/** Current model-surface membership accompanying one Conversation publication. */
-interface ConversationPresentation {
-  /** Loaded surface-event seqs that remain in the current model context. */
-  readonly currentSurfaceSeqs: ReadonlySet<number>
-}
-```
+The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. `ui-conversation` passes these entries to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
 
 | Concept | Owner and purpose |
 |---|---|

+ 1 - 11
docs/subsystems/conversation.zh.md

@@ -8,17 +8,7 @@ Conversation 是 Client `SessionEventLikeEntry` window 与浏览器 view 之间
 
 ## 数据模型与所有权
 
-Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }` 或 `{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。组装之前,`ConversationPresentationState` 会折叠显式 `conversationOp` 替换,从每个当前 target 中移除隐藏原始事件区间内的 entry,并为 target 自有动作计算已加载的当前 surface 成员关系。一次替换 append 会原子重建已加载窗口,因此任何 target 都不会发布新旧代次混合的中间状态。没有 `conversationOp` 的普通 surface 替换(包括压缩检查点)维持既有的人类 transcript 行为。
-
-`ui-conversation` 把每个被保留的 entry 直接交给 assembler,不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
-
-```ts type-equiv
-/** Current model-surface membership accompanying one Conversation publication. */
-interface ConversationPresentation {
-  /** Loaded surface-event seqs that remain in the current model context. */
-  readonly currentSurfaceSeqs: ReadonlySet<number>
-}
-```
+Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }` 或 `{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。`ui-conversation` 把这些 entry 直接交给 assembler,不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
 
 | 概念 | Owner 与用途 |
 |---|---|

+ 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: bf54bfb95ac41db665f67093568d340cb7438968
-core.zh.md: c7afa245445dab5ecacc5711f80ecb699f619558
+core.md: 170268351454643544d5ab20e3d4b6a8fd8c6c69
+core.zh.md: 048f7ed5f2136bb583c0308635e5cddde2b96785

+ 6 - 33
docs/subsystems/core.md

@@ -54,19 +54,7 @@ interface AgentHandle {
 
 `Agent` is the surface every plugin (UI, hooks, orchestrators) programs against; `ctx.agents.get(id)` returns it, and the [initiator scope](#initiating-agent) carries it. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it. The unified `send` method exposes target and wakeup routing directly; `followup`, `steer`, and `inject` are fixed-preset aliases.
 
-```ts type-equiv
-/** Optional routing and Session placement for one Agent inbox insertion. */
-interface AgentSendOptions {
-  /** Insert before existing work at the resolved target instead of after it. */
-  position?: 'front' | 'back'
-  /** Non-default placement used when AgentLoop records this exact message. */
-  surfaceIntent?: SurfaceIntent
-  /** Messages recorded immediately after the sent message in the same step. */
-  followingMessages?: readonly UserMessage[]
-}
-```
-
-Sources: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts), [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
+Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
 
 ```ts type-equiv
 /** Public live-agent handle; the runtime face augments its live capabilities. */
@@ -122,9 +110,8 @@ interface Agent {
    * @param message - identified content and the source that supplied it.
    * @param target - the preferred next-turn or next-step inbox boundary.
    * @param wakeup - whether delivery may wake the driver.
-   * @param options - optional queue position, Session placement, and same-step companions.
    */
-  send(message: UserMessage, target: InboxTarget, wakeup: boolean, options?: AgentSendOptions): void
+  send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
 
   /**
    * Queue an ordinary follow-up turn and wake the driver. The item becomes the
@@ -190,19 +177,7 @@ The inbox is the delivery vocabulary — two ordered pending-message lists the a
 type InboxTarget = 'next-turn' | 'next-step'
 ```
 
-```ts type-equiv
-/** Durable admission metadata attached to one inserted inbox message. */
-interface InboxAdmission {
-  /** Message identity the metadata follows through queue edits and claim. */
-  readonly messageId: MessageId
-  /** Exact placement applied when AgentLoop records the claimed user message. */
-  readonly surfaceIntent?: SurfaceIntent
-  /** Messages recorded immediately after the identified message in the same step. */
-  readonly followingMessages?: readonly UserMessage[]
-}
-```
-
-Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. An insertion may carry `InboxAdmission` metadata: non-default Session placement for its primary message and ordered messages that follow it in the same claimed step. `replace(messageId, newMessage)` transfers that metadata to the new identity. Ordinary removals and `clear()` are cancellations. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices, expands same-step companions, and emits per-message claimed notifications without treating the claim as a discard. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while 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. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` 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. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without emitting discarded notifications, and the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications.
 
 Cancellation:
 
@@ -241,7 +216,7 @@ Pre-step decisions use the same identified `UserMessage` type as durable user-ro
 
 Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
 
-`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), its non-default Session placement keyed by message identity (`surfaceIntents`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. A listener that may replace the Session surface defers while the map contains a preplanned replacement. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps:
+`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps:
 
 It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending:
 
@@ -977,18 +952,16 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
  * `next()` preserves the current messages.
  * @param payload.agent - the agent proposing the step.
  * @param payload.messages - messages removed from the inbox for this step.
- * @param payload.surfaceIntents - non-default Session placement retained
- * for claimed primary messages; the standard loop always supplies the map.
  * @param payload.turn - the turn that will own the step.
  * @param payload.step - the step proposed by the loop.
  * @param payload.signal - the current turn's cancellation signal.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
  * @mode waterfall
  */
-'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; surfaceIntents?: ReadonlyMap<MessageId, SurfaceIntent>; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
+'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
 ```
 
-Types: [MessageId](llm-streaming.md) · [Scoped](scope.md) · [SurfaceIntent](session.md) · [UserMessage](session.md)
+Types: [Scoped](scope.md) · [UserMessage](session.md)
 
 Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
 

+ 6 - 33
docs/subsystems/core.zh.md

@@ -58,19 +58,7 @@ interface AgentHandle {
 
 `Agent` 是每个插件(UI、钩子、orchestrator)面向编程的 surface;`ctx.agents.get(id)` 返回它,[发起者作用域](#initiating-agent)携带它。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。统一的 `send` 方法直接暴露 target 与 wakeup 路由;`followup`、`steer` 与 `inject` 是固定预设的别名方法。
 
-```ts type-equiv
-/** Optional routing and Session placement for one Agent inbox insertion. */
-interface AgentSendOptions {
-  /** Insert before existing work at the resolved target instead of after it. */
-  position?: 'front' | 'back'
-  /** Non-default placement used when AgentLoop records this exact message. */
-  surfaceIntent?: SurfaceIntent
-  /** Messages recorded immediately after the sent message in the same step. */
-  followingMessages?: readonly UserMessage[]
-}
-```
-
-源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)、[`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
+源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
 
 ```ts type-equiv
 /** Public live-agent handle; the runtime face augments its live capabilities. */
@@ -126,9 +114,8 @@ interface Agent {
    * @param message - identified content and the source that supplied it.
    * @param target - the preferred next-turn or next-step inbox boundary.
    * @param wakeup - whether delivery may wake the driver.
-   * @param options - optional queue position, Session placement, and same-step companions.
    */
-  send(message: UserMessage, target: InboxTarget, wakeup: boolean, options?: AgentSendOptions): void
+  send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
 
   /**
    * Queue an ordinary follow-up turn and wake the driver. The item becomes the
@@ -194,19 +181,7 @@ inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待
 type InboxTarget = 'next-turn' | 'next-step'
 ```
 
-```ts type-equiv
-/** Durable admission metadata attached to one inserted inbox message. */
-interface InboxAdmission {
-  /** Message identity the metadata follows through queue edits and claim. */
-  readonly messageId: MessageId
-  /** Exact placement applied when AgentLoop records the claimed user message. */
-  readonly surfaceIntent?: SurfaceIntent
-  /** Messages recorded immediately after the identified message in the same step. */
-  readonly followingMessages?: readonly UserMessage[]
-}
-```
-
-每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。一次插入可以携带 `InboxAdmission` 元数据:主消息的非默认 Session 放置方式,以及在同一个被领取步骤中紧随其后的有序消息。`replace(messageId, newMessage)` 会把这些元数据转移到新身份。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——展开同一步骤配套消息,并逐条发出 claimed 通知而不把领取视为 discarded。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
+每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。
 
 取消:
 
@@ -249,7 +224,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag
 
 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
 
-`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、按消息标识索引的非默认 Session 放置方式(`surfaceIntents`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。会替换 Session surface 的监听器在该映射包含预定替换时推迟操作。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次:
+`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次:
 
 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理:
 
@@ -987,18 +962,16 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
  * `next()` preserves the current messages.
  * @param payload.agent - the agent proposing the step.
  * @param payload.messages - messages removed from the inbox for this step.
- * @param payload.surfaceIntents - non-default Session placement retained
- * for claimed primary messages; the standard loop always supplies the map.
  * @param payload.turn - the turn that will own the step.
  * @param payload.step - the step proposed by the loop.
  * @param payload.signal - the current turn's cancellation signal.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
  * @mode waterfall
  */
-'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; surfaceIntents?: ReadonlyMap<MessageId, SurfaceIntent>; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
+'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
 ```
 
-Types: [MessageId](llm-streaming.zh.md) · [Scoped](scope.zh.md) · [SurfaceIntent](session.zh.md) · [UserMessage](session.zh.md)
+Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md)
 
 Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
 

+ 2 - 2
docs/subsystems/session-query.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session-query.md
-session-query.md: 6641f2b7320e7230bd4ed152f565a7661bc56d28
-session-query.zh.md: 793fc0f8db96f804ded616310a225da9af7e6e39
+session-query.md: 1cf3dff325db76485a0afca4ca1a882a4ff175b8
+session-query.zh.md: 2b8504dfb9ec8ee25d925135dbc99fa7beaede98

+ 3 - 3
docs/subsystems/session-query.md

@@ -8,7 +8,7 @@ Source: [`packages/session-query/session-query/src/types.ts`](../../packages/ses
 
 ## Logical records
 
-`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation and then marks every event in an explicit edited-conversation range as `shadowed`. The raw log remains readable through exact operations.
+`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation.
 
 ```ts type-equiv
 /** Whether an event is current model context, replaced context, or raw-log-only. */
@@ -99,7 +99,7 @@ interface SessionEventRecord {
   type: SessionEventType
   /** Event timestamp in Unix epoch milliseconds. */
   time: number
-  /** Event placement after model-surface and conversation-generation folding. */
+  /** Event placement in the folded session surface. */
   surface: SessionEventSurface
 }
 ```
@@ -146,7 +146,7 @@ interface SessionEventSearchDocument extends SessionEventRecord {
 
 ## Full-text search pages
 
-The combined `ctx.sessionQuery` seam has two full-text scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. Model-facing tools default both operations to `current` plus `log-only` and do not expose `shadowed`, so edited-away content is excluded without deleting its index records; programmatic callers may still request `shadowed` for diagnostics.
+The combined `ctx.sessionQuery` seam has two full-text scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters.
 
 ```ts type-equiv
 /** Provider-owned opaque continuation token returned by session search. */

+ 3 - 3
docs/subsystems/session-query.zh.md

@@ -8,7 +8,7 @@
 
 ## 逻辑记录
 
-`SessionRecord` 由全语料库列表返回。它除了克隆的、优先取自 live 源的 header 外,还单独公开各源的可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与模型历史推导相同的 `foldSurface()` 状态转换,随后把显式编辑对话区间内的每个事件标记为 `shadowed`。原始日志仍可通过精确操作读取
+`SessionRecord` 由全语料库列表返回。它除了克隆的、优先取自 live 源的 header 外,还单独公开各源的可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与模型历史推导相同的 `foldSurface()` 状态转换。
 
 ```ts type-equiv
 /** Whether an event is current model context, replaced context, or raw-log-only. */
@@ -99,7 +99,7 @@ interface SessionEventRecord {
   type: SessionEventType
   /** Event timestamp in Unix epoch milliseconds. */
   time: number
-  /** Event placement after model-surface and conversation-generation folding. */
+  /** Event placement in the folded session surface. */
   surface: SessionEventSurface
 }
 ```
@@ -146,7 +146,7 @@ interface SessionEventSearchDocument extends SessionEventRecord {
 
 ## 全文搜索结果页
 
-整合后的 `ctx.sessionQuery` seam 提供两个全文搜索范围。`searchSessions()` 按匹配度最强的事件对语料库分组;`searchEvents()` 搜索单个会话。请求将不透明游标与规范化后的查询、元数据过滤器和结果数量上限绑定。提供方的元数据过滤器有意不包含事件文本扫描。面向模型的工具默认让两个操作只查询 `current` 与 `log-only`,且不公开 `shadowed`,因此被编辑替换的内容无需删除索引记录也会被排除;程序化调用方仍可显式请求 `shadowed` 进行诊断。
+整合后的 `ctx.sessionQuery` seam 提供两个全文搜索范围。`searchSessions()` 按匹配度最强的事件对语料库分组;`searchEvents()` 搜索单个会话。请求将不透明游标与规范化后的查询、元数据过滤器和结果数量上限绑定。提供方的元数据过滤器有意不包含事件文本扫描。
 
 ```ts type-equiv
 /** Provider-owned opaque continuation token returned by session search. */

+ 2 - 2
docs/subsystems/session-title.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session-title.md
-session-title.md: c346fef215db8d98c12d601a1bab0690df455429
-session-title.zh.md: a42a9e762686225a0007c50eab771faacea0d4ce
+session-title.md: 20ed32b4a08127e7193433673d22f0dea3a90c46
+session-title.zh.md: c01d5d4bf177169069bbe0a39ef533ada0f7c36a

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

@@ -86,7 +86,7 @@ interface SessionTitleLlmRequestEventData {
 
 ## Provider input and output
 
-The service snapshots eligible messages through one revision. Only append-origin human messages schedule automatic work, so a same-session edit does not regenerate the current title; a later ordinary prompt builds its provider request from the then-current conversation generation and omits edited-away prompts. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind.
+The service snapshots eligible messages through one revision. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind.
 
 ```ts type-equiv
 /** One eligible human text message exposed to title providers. */
@@ -108,7 +108,7 @@ type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts'
 interface SessionTitleProviderRequest {
   /** Live session being titled. */
   readonly session: Session
-  /** Current-generation eligible human messages through this title revision. */
+  /** All eligible human messages through this generation revision. */
   readonly messages: readonly SessionTitleUserMessage[]
   /** Exact current logged main-request route, when one has been recorded. */
   readonly route?: SessionTitleModelProvenance

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

@@ -86,7 +86,7 @@ interface SessionTitleLlmRequestEventData {
 
 ## 提供方输入与输出
 
-服务会对截至某一修订的合格消息创建快照。只有追加来源的人工消息会安排自动工作,因此同会话编辑不会重新生成当前标题;后续普通提示词会从当时的当前对话代次构建提供方请求,并省略被编辑隐藏的提示词。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。
+服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。
 
 ```ts type-equiv
 /** One eligible human text message exposed to title providers. */
@@ -108,7 +108,7 @@ type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts'
 interface SessionTitleProviderRequest {
   /** Live session being titled. */
   readonly session: Session
-  /** Current-generation eligible human messages through this title revision. */
+  /** All eligible human messages through this generation revision. */
   readonly messages: readonly SessionTitleUserMessage[]
   /** Exact current logged main-request route, when one has been recorded. */
   readonly route?: SessionTitleModelProvenance

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 11ad6a4d823322fe6fcbbe394be80b41dde5d0dd
-session.zh.md: bc21494320fcacdf6e4d2180a67c36ecd938d444
+session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31
+session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd

+ 6 - 62
docs/subsystems/session.md

@@ -208,8 +208,7 @@ type OptionalSessionSeq = SessionSeq | null
  * A proper discriminated union over `type` (not independent `type`/`data`
  * unions), so `switch (event.type)` narrows `event.data` without casts.
  *
- * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp}
- * fields are conditional:
+ * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
  * they only exist on {@link SurfaceEventType} variants (`user/message`,
  * `assistant/message`, `tool/result`).
  * Non-surface events (boundary markers, chunks, usage, errors) never carry
@@ -247,8 +246,6 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
     sourceEventSeqs?: SessionSeq[]
     /** How this event entered the surface; absent for non-surface events. */
     surfaceOp?: SurfaceOp
-    /** Raw event range this message replaces in current conversation projections. */
-    conversationOp?: ConversationOp
   } : object)
 }[T]
 ```
@@ -298,48 +295,6 @@ type SurfaceOp =
 
 `'append'` is the normal tail-append path. `replace` shadows surface entries from `start` through `end` inclusive (both must be valid surface seqs; `start === end` replaces a single entry) and inserts the new event in their place.
 
-### `ConversationOp` — current user-facing generation replacement
-
-```ts type-equiv
-/**
- * How one message starts a new user-facing conversation generation.
- *
- * The inclusive raw-event range remains in the append-only log but is omitted
- * from current conversation projections. Unlike {@link SurfaceOp}, this range
- * covers every event family rendered by Chat, Trajectory, search, and
- * transcript exporters rather than only model-message surface nodes.
- */
-type ConversationOp = { op: 'replace'; start: SessionSeq; end: SessionSeq }
-```
-
-Only a replacement `user/message` may carry `conversationOp`. Its inclusive range must precede the replacement event. `foldConversation()` merges overlapping or adjacent ranges, and membership checks use binary search over that normalized result.
-
-```ts type-equiv
-/** One committed user-facing replacement and the event that committed it. */
-interface ConversationReplacement extends ConversationOp {
-  /** Seq of the replacement `user/message`. */
-  readonly seq: SessionSeq
-}
-```
-
-```ts type-equiv
-/** A merged inclusive interval hidden from the current conversation. */
-interface ConversationHiddenRange {
-  readonly start: SessionSeq
-  readonly end: SessionSeq
-}
-```
-
-```ts type-equiv
-/** Complete result of folding conversation replacements from one event window. */
-interface ConversationFoldResult {
-  /** Replacement operations in event order. */
-  readonly replacements: readonly ConversationReplacement[]
-  /** Sorted, non-overlapping raw-event ranges hidden by those replacements. */
-  readonly hiddenRanges: readonly ConversationHiddenRange[]
-}
-```
-
 ### `SurfaceIntent` — the parameter to `session.append()`
 
 ```ts type-equiv
@@ -356,12 +311,10 @@ interface SurfaceIntent {
    * Other surface events require a non-empty set when this field is present.
    */
   sourceEventSeqs?: SessionSeq[]
-  /** Optional user-facing conversation replacement committed with this message. */
-  conversationOp?: ConversationOp
 }
 ```
 
-Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. Human-facing projections normally read append-origin events so compaction remains visible as history; an explicit `conversationOp` is the narrow exception that replaces one user-visible generation without deleting it from the log. Non-surface types reject surface metadata at compile time.
+Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. A human-facing transcript is the other projection and reads the log's append-origin events instead, because the surface deliberately shadows the ranges a replacement summarizes (`isAppendSurfaceEvent` in [dsh-session](../../packages/core/session/README.md)). Non-surface types reject it at compile time.
 
 Only `assistant/message` may carry a present empty `sourceEventSeqs`; when the field is absent, the event does not record which earlier events produced the message, and the provider may still have emitted chunks.
 
@@ -535,11 +488,10 @@ declare class Session {
    * @param data - The event payload; must be JSON-serializable.
    * @param opts - Surface metadata: `surfaceOp` controls how the event enters
    *   the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier
-   *   events this one derives from; `conversationOp` lets a replacement user
-   *   message hide one earlier raw-event range from current conversation
-   *   views. REQUIRED for {@link SurfaceEventType} events (every
-   *   message-producing event must declare how it joins the surface, the sole
-   *   source of derived model history) and
+   *   events this one derives from. REQUIRED for
+   *   {@link SurfaceEventType} events (every message-producing event must
+   *   declare how it joins the surface, the sole source of derived model
+   *   history) and
    *   rejected by the compiler for non-surface types like `turn/start` or
    *   `assistant/chunk`.
    * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
@@ -805,14 +757,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
 
-/**
- * Replace the latest current turn-opening user message and rerun from that point.
- * @param request - target message, optimistic revision, and replacement text.
- * @param signal - caller cancellation before the replacement is admitted.
- * @returns acknowledgement after the replacement message commits.
- */
-@Remote('edit') edit(request: SessionEditRequest, signal: AbortSignal): Promise<SessionEditValue>
-
 /**
  * Read one image proven reachable from the addressed Session log.
  * @param request - Session and attachment identities used for authorization.

+ 6 - 62
docs/subsystems/session.zh.md

@@ -208,8 +208,7 @@ type OptionalSessionSeq = SessionSeq | null
  * A proper discriminated union over `type` (not independent `type`/`data`
  * unions), so `switch (event.type)` narrows `event.data` without casts.
  *
- * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp}
- * fields are conditional:
+ * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
  * they only exist on {@link SurfaceEventType} variants (`user/message`,
  * `assistant/message`, `tool/result`).
  * Non-surface events (boundary markers, chunks, usage, errors) never carry
@@ -247,8 +246,6 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
     sourceEventSeqs?: SessionSeq[]
     /** How this event entered the surface; absent for non-surface events. */
     surfaceOp?: SurfaceOp
-    /** Raw event range this message replaces in current conversation projections. */
-    conversationOp?: ConversationOp
   } : object)
 }[T]
 ```
@@ -300,48 +297,6 @@ type SurfaceOp =
 
 `'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。
 
-### `ConversationOp`:当前用户可见对话代次替换
-
-```ts type-equiv
-/**
- * How one message starts a new user-facing conversation generation.
- *
- * The inclusive raw-event range remains in the append-only log but is omitted
- * from current conversation projections. Unlike {@link SurfaceOp}, this range
- * covers every event family rendered by Chat, Trajectory, search, and
- * transcript exporters rather than only model-message surface nodes.
- */
-type ConversationOp = { op: 'replace'; start: SessionSeq; end: SessionSeq }
-```
-
-只有替换型 `user/message` 可以携带 `conversationOp`,且其闭区间必须位于替换事件之前。`foldConversation()` 会合并重叠或相邻区间,成员关系检查则在规范化结果上使用二分查找。
-
-```ts type-equiv
-/** One committed user-facing replacement and the event that committed it. */
-interface ConversationReplacement extends ConversationOp {
-  /** Seq of the replacement `user/message`. */
-  readonly seq: SessionSeq
-}
-```
-
-```ts type-equiv
-/** A merged inclusive interval hidden from the current conversation. */
-interface ConversationHiddenRange {
-  readonly start: SessionSeq
-  readonly end: SessionSeq
-}
-```
-
-```ts type-equiv
-/** Complete result of folding conversation replacements from one event window. */
-interface ConversationFoldResult {
-  /** Replacement operations in event order. */
-  readonly replacements: readonly ConversationReplacement[]
-  /** Sorted, non-overlapping raw-event ranges hidden by those replacements. */
-  readonly hiddenRanges: readonly ConversationHiddenRange[]
-}
-```
-
 ### `SurfaceIntent`:`session.append()` 的参数
 
 ```ts type-equiv
@@ -358,12 +313,10 @@ interface SurfaceIntent {
    * Other surface events require a non-empty set when this field is present.
    */
   sourceEventSeqs?: SessionSeq[]
-  /** Optional user-facing conversation replacement committed with this message. */
-  conversationOp?: ConversationOp
 }
 ```
 
-对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的投影通常读取追加来源事件,使压缩后仍保留历史;显式 `conversationOp` 是一个窄例外,它替换一代用户可见对话,但不从日志删除原事件。非 surface 类型在编译期拒绝 surface 元数据
+对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的 transcript(文本记录)是另一个投影,读取的是日志中追加来源的事件,因为 surface 会有意遮蔽替换所概括的范围(见 [dsh-session](../../packages/core/session/README.zh.md) 的 `isAppendSurfaceEvent`)。非 surface 类型在编译期拒绝此参数
 
 只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;字段不存在时,该事件没有记录这条消息由哪些早期事件产生,但提供方仍可能发出过分片。
 
@@ -537,11 +490,10 @@ declare class Session {
    * @param data - The event payload; must be JSON-serializable.
    * @param opts - Surface metadata: `surfaceOp` controls how the event enters
    *   the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier
-   *   events this one derives from; `conversationOp` lets a replacement user
-   *   message hide one earlier raw-event range from current conversation
-   *   views. REQUIRED for {@link SurfaceEventType} events (every
-   *   message-producing event must declare how it joins the surface, the sole
-   *   source of derived model history) and
+   *   events this one derives from. REQUIRED for
+   *   {@link SurfaceEventType} events (every message-producing event must
+   *   declare how it joins the surface, the sole source of derived model
+   *   history) and
    *   rejected by the compiler for non-surface types like `turn/start` or
    *   `assistant/chunk`.
    * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
@@ -809,14 +761,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
  */
 @Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
 
-/**
- * Replace the latest current turn-opening user message and rerun from that point.
- * @param request - target message, optimistic revision, and replacement text.
- * @param signal - caller cancellation before the replacement is admitted.
- * @returns acknowledgement after the replacement message commits.
- */
-@Remote('edit') edit(request: SessionEditRequest, signal: AbortSignal): Promise<SessionEditValue>
-
 /**
  * Read one image proven reachable from the addressed Session log.
  * @param request - Session and attachment identities used for authorization.

+ 2 - 2
docs/tool-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/tool-catalog.md
-tool-catalog.md: 9e38a9503ccfb91a18f0e27184138016b747d56d
-tool-catalog.zh.md: c323ec2c8ee333715ea8d78bb1626e740d060afc
+tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b
+tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d

+ 4 - 2
docs/tool-catalog.md

@@ -1375,11 +1375,12 @@ Search prior events in one authorized session; the current session excludes the
     },
     "surfaces": {
       "type": "array",
-      "description": "Current model-surface or active log-only events to include.",
+      "description": "Event surfaces to include.",
       "items": {
         "type": "string",
         "enum": [
           "current",
+          "shadowed",
           "log-only"
         ]
       }
@@ -1492,11 +1493,12 @@ Search prior sessions in the caller workspace and return the strongest matching
     },
     "event_surfaces": {
       "type": "array",
-      "description": "Current model-surface or active log-only events to include.",
+      "description": "Event surfaces to include.",
       "items": {
         "type": "string",
         "enum": [
           "current",
+          "shadowed",
           "log-only"
         ]
       }

+ 4 - 2
docs/tool-catalog.zh.md

@@ -1381,11 +1381,12 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
     },
     "surfaces": {
       "type": "array",
-      "description": "Current model-surface or active log-only events to include.",
+      "description": "Event surfaces to include.",
       "items": {
         "type": "string",
         "enum": [
           "current",
+          "shadowed",
           "log-only"
         ]
       }
@@ -1498,11 +1499,12 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
     },
     "event_surfaces": {
       "type": "array",
-      "description": "Current model-surface or active log-only events to include.",
+      "description": "Event surfaces to include.",
       "items": {
         "type": "string",
         "enum": [
           "current",
+          "shadowed",
           "log-only"
         ]
       }

+ 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: 1747833eae24d0584c9ce309317e621db106482c
-README.zh.md: 8c6348aac1bec69492c67f288eba4ee3a9921ae0
+README.md: cf78a247e27ec37ff51b10bad3f925348b9e01a2
+README.zh.md: 06d61851eec1ffdc99ed9348fac8b72cbc745b50

+ 4 - 6
packages/api/session-controller/README.md

@@ -25,13 +25,11 @@ English | [中文](README.zh.md)
 
 History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
 
-Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, edit, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
-
-`session.edit` accepts only the latest human message when it is a current-surface, turn-opening prompt in an ordinary Session. The request carries that latest observed human-message seq as an optimistic revision; the Host revalidates it while holding idle maintenance, cancels a running turn with its inbox preserved, and places the edited rerun ahead of existing Queue work. A concurrent maintenance reservation reports `session/agent-busy`, a changed target reports `session/edit-stale`, and unexpected admission faults report `gateway/internal`. The replacement keeps non-text content, retained session-reference context, and the current model selection. Its new `user/message` commits both a model-surface replacement and a user-facing conversation replacement before the RPC acknowledges success. Edit does not create a Session, regenerate its title, or roll back external side effects from the hidden turns.
+Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
 
 The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
 
-The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. `SessionSnapshot.pendingEdit` applies the same handoff rule to one optimistic replacement: Chat hides the selected suffix immediately, and the echo retires only after the replacement event is renderable or the operation fails. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
+The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
 
 -----
 
@@ -51,11 +49,11 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 <a id="model-experience"></a>
 ## Model Experience
 
-None, as the controller delegates the replacement request and its model-visible history to the Agent and Session surface.
+None, as invoked Agent commands own any model-visible effect.
 
 #### KV Cache effect
 
-No direct effect; the delegated surface replacement starts a new request series and preserves reuse only through the unchanged prefix before the edited turn.
+No direct effect; model requests remain owned by the Agent and LLM packages.
 
 ## Known Limitations and Deferred Work
 

+ 4 - 6
packages/api/session-controller/README.zh.md

@@ -25,13 +25,11 @@ kind: "package-reference"
 
 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
 
-每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、edit 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
-
-`session.edit` 只接受普通 Session 中最新的人工消息,且它必须是仍位于当前 surface 的轮次开场提示词。请求把该最新已观测人工消息的 seq 作为乐观 revision;Host 在持有 idle maintenance 时重新校验,使用保留 inbox 的方式取消运行中轮次,并把编辑后的重跑放到现有 Queue 工作之前。并发 maintenance 占用报告 `session/agent-busy`,目标发生变化报告 `session/edit-stale`,意外准入故障报告 `gateway/internal`。替换会保留非文本内容、已有的会话引用上下文与当前模型选择。新的 `user/message` 同时提交模型 surface 替换和用户可见对话替换,RPC 随后才确认成功。Edit 不会创建新 Session、重新生成标题,也不会回滚被隐藏轮次已经产生的外部副作用。
+每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
 
 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
 
-Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。`SessionSnapshot.pendingEdit` 对一次乐观替换应用相同的交接规则:Chat 会立刻隐藏选中消息起的后缀,只有替换事件已可渲染或操作失败后,回显才会退休。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
+Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
 
 -----
 
@@ -51,11 +49,11 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
 <a id="model-experience"></a>
 ## 模型体验
 
-无,因为 controller 会把替换请求及其模型可见历史委托给 Agent 与 Session surface
+无,因为被调用的 Agent 命令拥有任何模型可见效果
 
 #### KV Cache 影响
 
-无直接影响;被委托的 surface 替换会开启新的请求序列,只有编辑轮次之前未变化的前缀仍可能复用
+无直接影响;模型请求仍由 Agent 和 LLM 包拥有
 
 ## 已知限制与延期工作
 

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

@@ -86,20 +86,6 @@ export interface ISession {
     signal?: AbortSignal,
     requestId?: SessionRequestId,
   ): Promise<RemoteResult<{ accepted: true }>>
-  /**
-   * Replace the latest current turn-opening human message and rerun from it.
-   * @param messageSeq - selected durable user-message event.
-   * @param expectedLastUserSeq - optimistic conversation revision captured by the editor.
-   * @param text - replacement text; retained non-text blocks remain Host-owned.
-   * @param signal - optional caller cancellation before admission.
-   * @returns the committed replacement event seq, or a business error.
-   */
-  edit(
-    messageSeq: number,
-    expectedLastUserSeq: number,
-    text: string,
-    signal?: AbortSignal,
-  ): Promise<RemoteResult<{ accepted: true; messageSeq: number }>>
   /**
    * Resolve one durable image referenced by this session.
    * @param attachmentId - opaque id found in the folded session log.

+ 1 - 17
packages/api/session-controller/src/client/contract/snapshot.ts

@@ -52,26 +52,12 @@ export interface PendingSubmission {
   readonly images: readonly PendingSubmissionImage[]
 }
 
-/** One local same-session edit awaiting its durable replacement message. */
-export interface PendingEdit {
-  /** RPC identity echoed by the replacement `user/message`. */
-  readonly requestId: SessionRequestId
-  /** Existing user-message event being replaced. */
-  readonly targetSeq: number
-  /** Latest human message observed when edit mode opened. */
-  readonly expectedLastUserSeq: number
-  /** Replacement text submitted by the inline editor. */
-  readonly text: string
-  /** Client wall-clock ms used by the optimistic replacement bubble. */
-  readonly time: number
-}
-
 /** History-open lifecycle of a Session event window. */
 export type OpenState = 'cold' | 'loading' | 'open' | 'error'
 
 /** Send/stop failure surfaced by Session consumers. */
 export interface PromptError {
-  readonly op: 'send' | 'stop' | 'edit'
+  readonly op: 'send' | 'stop'
   readonly error: RemoteFailure
 }
 
@@ -81,8 +67,6 @@ export interface SessionSnapshot {
   readonly queue: readonly QueuedMessage[]
   /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */
   readonly pendingSubmissions: readonly PendingSubmission[]
-  /** Same-session edit waiting for its replacement user message. */
-  readonly pendingEdit: PendingEdit | null
   readonly running: boolean
   readonly subagent: {
     readonly address: SubagentAddress

+ 1 - 59
packages/api/session-controller/src/client/sessions/session.ts

@@ -21,7 +21,7 @@ import type {
   BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle,
 } from '../contract/session.ts'
 import type {
-  OpenState, PendingEdit, PendingSubmission, PromptError, SessionSnapshot,
+  OpenState, PendingSubmission, PromptError, SessionSnapshot,
 } from '../contract/snapshot.ts'
 import { MutableSessionEventSource } from '../contract/events.ts'
 import type {
@@ -113,7 +113,6 @@ export class Session implements SessionFace {
   private lastAgentError: string | null = null
   /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */
   private pendingSubmissions: readonly PendingSubmission[] = []
-  private pendingEdit: PendingEdit | null = null
   /** Per-echo settlement state; `retiring` latches the first observation so a
    *  queue frame and its durable event cannot both retire one echo. */
   private readonly submissionSettlements = new Map<SessionRequestId, {
@@ -280,45 +279,6 @@ export class Session implements SessionFace {
     return result
   }
 
-  /** Submit one same-session edit with an immediate optimistic replacement. */
-  async edit(
-    messageSeq: number,
-    expectedLastUserSeq: number,
-    text: string,
-    signal?: AbortSignal,
-  ): Promise<RemoteResult<{ accepted: true; messageSeq: number }>> {
-    const requestId = randomUUID() as SessionRequestId
-    this.pendingEdit = { requestId, targetSeq: messageSeq, expectedLastUserSeq, text, time: Date.now() }
-    this.promptError = null
-    this.lastAgentError = null
-    this.notifier.markDirty()
-    let result: RemoteResult<{ accepted: true; messageSeq: number }>
-    try {
-      result = await this.remote.session.edit({
-        requestId,
-        sessionId: this.sessionId,
-        messageSeq,
-        expectedLastUserSeq,
-        text,
-        clientTimeZone: resolvedClientTimeZone(),
-      }, signal)
-    } catch (error: unknown) {
-      const cleared = this.clearPendingEdit(requestId)
-      if (isRemoteFailure(error)) {
-        this.promptError = { op: 'edit', error }
-        this.notifier.markDirty()
-        return { ok: false, error }
-      }
-      if (cleared) this.notifier.markDirty()
-      throw error
-    }
-    if (!result.ok && this.clearPendingEdit(requestId)) {
-      this.promptError = { op: 'edit', error: result.error }
-      this.notifier.markDirty()
-    }
-    return result
-  }
-
   /**
    * Resolve one image referenced by this session into browser-consumable bytes.
    * @param attachmentId - opaque id found in the folded session log.
@@ -622,12 +582,6 @@ export class Session implements SessionFace {
 
   // ---- Private ----
 
-  private clearPendingEdit(requestId: SessionRequestId): boolean {
-    if (this.pendingEdit === null || this.pendingEdit.requestId !== requestId) return false
-    this.pendingEdit = null
-    return true
-  }
-
   /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */
   private async doOpen(generation: number): Promise<void> {
     this.openState = 'loading'
@@ -710,17 +664,6 @@ export class Session implements SessionFace {
 
   /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */
   private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void {
-    if (event.type === 'user/message' && this.pendingEdit !== null) {
-      const source = (event.data as { readonly source?: { readonly rpcId?: unknown } } | undefined)?.source
-      if (source?.rpcId === this.pendingEdit.requestId) {
-        const requestId = this.pendingEdit.requestId
-        scheduleFrame(() => {
-          if (this.pendingEdit?.requestId !== requestId) return
-          this.pendingEdit = null
-          this.notifier.markDirty()
-        })
-      }
-    }
     if (this.submissionSettlements.size === 0 || 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
@@ -794,7 +737,6 @@ export class Session implements SessionFace {
       sessionId: this.sessionId,
       queue: this.queueMirror.snapshot(),
       pendingSubmissions: this.pendingSubmissions,
-      pendingEdit: this.pendingEdit,
       running: this.running,
       subagent: this.address === undefined
         ? null

+ 2 - 366
packages/api/session-controller/src/commands.ts

@@ -9,9 +9,9 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import {
   ReasoningEffortId, createUserMessage, freezeMessage,
 } from '@deepseek-ai/dsh-llm'
-import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
+import type { MessageSource } from '@deepseek-ai/dsh-llm'
 import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
 import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
@@ -34,8 +34,6 @@ import type {
   SessionCancelValue,
   SessionCreateRequest,
   SessionCreateValue,
-  SessionEditRequest,
-  SessionEditValue,
   SessionForkRequest,
   SessionForkValue,
   SessionPromptRequest,
@@ -54,35 +52,6 @@ interface SessionReadState {
   readonly events: readonly SessionEvent[]
 }
 
-/** One admitted edit and the exact replacement event it is waiting to commit. */
-interface EditAdmission {
-  readonly committed: Promise<SessionEvent<'user/message'>>
-}
-
-/** Expected loss of the exact edit admission after it was queued. */
-class EditAdmissionStale extends Error {}
-
-/** Preserve the Agent maintenance refusal as a retryable Session-domain failure. */
-function editMaintenanceBusy(agent: Agent, error: unknown): RemoteError<'session/agent-busy'> {
-  return new RemoteError(
-    'session/agent-busy',
-    `session "${agent.id}" is busy with another maintenance operation`,
-    { reason: String(error) },
-    { cause: error },
-  )
-}
-
-/** Validated replacement ranges and original content for one edit request. */
-interface EditTarget {
-  readonly event: SessionEvent<'user/message'>
-  readonly turnStartSeq: SessionSeq
-  readonly rawEndSeq: SessionSeq
-  readonly surfaceStart: SessionSeq
-  readonly surfaceEnd: SessionSeq
-  readonly shadowedSurfaceSeqs: SessionSeq[]
-  readonly preservedContexts: readonly SessionEvent<'user/message'>[]
-}
-
 /** Implements Session business commands delegated by the Session Controller Remote service. */
 export class SessionCommandController {
   /**
@@ -371,93 +340,6 @@ export class SessionCommandController {
     return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
   }
 
-  /**
-   * Replace the latest current turn-opening human message and prioritize its rerun
-   * ahead of already queued turns.
-   * @param request - target message, optimistic revision, and replacement text.
-   * @param signal - caller cancellation observed before inbox admission.
-   * @returns acknowledgement after the replacement message enters the log.
-   */
-  async edit(request: SessionEditRequest, signal: AbortSignal): Promise<SessionEditValue> {
-    validateEditRequest(request)
-    signal.throwIfAborted()
-    const agent = await this.resolveAgent(request.sessionId)
-    const initialTarget = resolveEditTarget(agent.session, request)
-    replaceMessageText(initialTarget.event.data.content, request.text)
-    const hasImage = initialTarget.event.data.content.some(block => block.type === 'image')
-    const clientTimeZone = request.clientTimeZone === undefined
-      ? undefined
-      : canonicalClientTimeZone(request.clientTimeZone)
-    if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
-      throw new RemoteError(
-        'session/invalid-time-zone',
-        'clientTimeZone must be UTC or a valid IANA Area/Location name',
-        { value: request.clientTimeZone },
-      )
-    }
-    const admit = async (): Promise<SessionEditValue> => {
-      const selection = this.agents.selectionFor(agent).current
-      if (!routeServed(this.ctx, selection.provider)) {
-        throw new RemoteError(
-          'session/model-unavailable',
-          `no adapter serves provider "${selection.provider}"; select a model for this session`,
-          { provider: selection.provider, model: selection.model },
-        )
-      }
-      if (hasImage) {
-        const model = await this.ctx.llm.resolveModelInfo(selection.provider, selection.model)
-        if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
-          throw new RemoteError(
-            'session/attachment-invalid',
-            `Model "${selection.model}" does not support image input.`,
-            { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
-          )
-        }
-      }
-      let committed: SessionEvent<'user/message'>
-      try {
-        let admission: EditAdmission
-        if (agent.status === 'running') {
-          admission = await this.reserveMaintenanceAfterCancel(agent, signal, () => {
-            signal.throwIfAborted()
-            return Promise.resolve(this.admitEdit(agent, request, clientTimeZone))
-          })
-        } else {
-          let maintenance: Promise<EditAdmission>
-          try {
-            maintenance = agent.runMaintenance((maintenanceSignal) => {
-              signal.throwIfAborted()
-              maintenanceSignal.throwIfAborted()
-              return Promise.resolve(this.admitEdit(agent, request, clientTimeZone))
-            })
-          } catch (error: unknown) {
-            throw editMaintenanceBusy(agent, error)
-          }
-          admission = await maintenance
-        }
-        committed = await admission.committed
-      } catch (error: unknown) {
-        if (remoteErrorOf(error) !== undefined || signal.aborted) throw error
-        if (error instanceof EditAdmissionStale) {
-          throw new RemoteError(
-            'session/edit-stale',
-            `session "${request.sessionId}" changed before the edit could be admitted`,
-            { sessionId: request.sessionId, messageSeq: request.messageSeq },
-            { cause: error },
-          )
-        }
-        throw new RemoteError(
-          'gateway/internal',
-          `session "${request.sessionId}" edit admission failed: ${String(error)}`,
-          {},
-          { cause: error },
-        )
-      }
-      return { accepted: true, messageSeq: committed.seq }
-    }
-    return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
-  }
-
   /**
    * Read one durable image after proving the Session log references it.
    * @param request - Session and attachment identities used for authorization.
@@ -602,92 +484,6 @@ export class SessionCommandController {
     return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
   }
 
-  /** Cancel a running turn and synchronously reserve maintenance at its idle transition. */
-  private reserveMaintenanceAfterCancel<Value>(
-    agent: Agent,
-    signal: AbortSignal,
-    operation: (signal: AbortSignal) => Promise<Value>,
-  ): Promise<Value> {
-    signal.throwIfAborted()
-    return new Promise<Value>((resolve, reject) => {
-      let settled = false
-      const finish = (result: Promise<Value>): void => {
-        /* v8 ignore next -- every finishing path removes the other listeners before yielding. */
-        if (settled) return
-        settled = true
-        dispose()
-        signal.removeEventListener('abort', abort)
-        result.then(resolve, reject)
-      }
-      const abort = (): void => {
-        const reason = signal.reason instanceof Error
-          ? signal.reason
-          : new Error('edit request aborted', { cause: signal.reason })
-        finish(Promise.reject(reason))
-      }
-      const dispose = this.ctx.on('agent/status', ({ agent: subject, status }) => {
-        if (subject !== agent || status !== 'idle') return
-        try {
-          finish(agent.runMaintenance(operation))
-        } catch (error: unknown) {
-          finish(Promise.reject(editMaintenanceBusy(agent, error)))
-        }
-      }, { global: true })
-      signal.addEventListener('abort', abort, { once: true })
-      try {
-        agent.cancel({ kind: 'user' }, { keepInbox: true })
-      } catch (error: unknown) {
-        finish(Promise.reject(error instanceof Error ? error : new Error('edit cancellation failed', { cause: error })))
-      }
-    })
-  }
-
-  /** Validate and enqueue one edit while maintenance owns the idle Agent. */
-  private admitEdit(
-    agent: Agent,
-    request: SessionEditRequest,
-    clientTimeZone: string | undefined,
-  ): EditAdmission {
-    const target = resolveEditTarget(agent.session, request)
-    const source: MessageSource = {
-      kind: 'user',
-      rpcId: request.requestId,
-      ...(clientTimeZone === undefined ? {} : { clientTimeZone }),
-    }
-    const message = createUserMessage({
-      content: replaceMessageText(target.event.data.content, request.text),
-      source,
-    })
-    const followingMessages = target.preservedContexts.map(context => createUserMessage({
-      content: [...context.data.content],
-      source: context.data.source,
-    }))
-    const waiter = waitForEditedMessage(this.ctx, agent, message.id)
-    try {
-      agent.send(message, 'next-turn', true, {
-        position: 'front',
-        followingMessages,
-        surfaceIntent: {
-          surfaceOp: {
-            op: 'replace',
-            start: target.surfaceStart,
-            end: target.surfaceEnd,
-          },
-          sourceEventSeqs: target.shadowedSurfaceSeqs,
-          conversationOp: {
-            op: 'replace',
-            start: target.turnStartSeq,
-            end: target.rawEndSeq,
-          },
-        },
-      })
-    } catch (error: unknown) {
-      waiter.dispose()
-      throw error
-    }
-    return { committed: waiter.committed }
-  }
-
   private async forkWorkspace(source: SessionHeader): Promise<Workspace | undefined> {
     const workspaces = this.ctx.workspaceRegistry.list()
     const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
@@ -701,166 +497,6 @@ export class SessionCommandController {
   }
 }
 
-/** Validate scalar fields before resolving or interrupting an Agent. */
-function validateEditRequest(request: SessionEditRequest): void {
-  if (!Number.isSafeInteger(request.messageSeq) || request.messageSeq < 0) {
-    throw new RemoteError('gateway/bad-request', 'messageSeq must be a non-negative safe integer', {})
-  }
-  if (!Number.isSafeInteger(request.expectedLastUserSeq) || request.expectedLastUserSeq < 0) {
-    throw new RemoteError('gateway/bad-request', 'expectedLastUserSeq must be a non-negative safe integer', {})
-  }
-}
-
-/** Resolve the exact current turn and model-surface suffix replaced by an edit. */
-function resolveEditTarget(session: Session, request: SessionEditRequest): EditTarget {
-  const events = session.snapshotEvents()
-  const latestUser = events.findLast(event =>
-    event.type === 'user/message' && event.data.source.kind === 'user')
-  if (latestUser?.seq !== request.expectedLastUserSeq) {
-    throw new RemoteError(
-      'session/edit-stale',
-      `session "${request.sessionId}" changed after message ${String(request.messageSeq)} entered edit mode`,
-      { sessionId: request.sessionId, messageSeq: request.messageSeq },
-    )
-  }
-  const target = events[request.messageSeq]
-  if (target?.type !== 'user/message'
-    || target.data.source.kind !== 'user'
-    || !target.data.content.some(block => block.type === 'text')) {
-    throw editUnavailable(request, 'the selected event is not an editable human message')
-  }
-  if (target.seq !== latestUser.seq) {
-    throw editUnavailable(request, 'only the latest human message can be edited')
-  }
-  const currentSurface = session.surface.nodes
-  if (!currentSurface.includes(target.seq)) {
-    throw editUnavailable(request, 'the selected message is not in the current model context')
-  }
-  const turnStart = events.slice(0, target.seq + 1).findLast(event => event.type === 'turn/start')
-  if (turnStart?.type !== 'turn/start') {
-    throw editUnavailable(request, 'the selected message has no owning turn')
-  }
-  const firstStepStart = events.find(event =>
-    event.seq > turnStart.seq
-    && event.type === 'step/start'
-    && event.data.turn === turnStart.data.turn)
-  if (firstStepStart?.type !== 'step/start' || firstStepStart.data.step !== 1) {
-    throw editUnavailable(request, 'the selected message has no first step')
-  }
-  const stepEndIndex = events.findIndex(event =>
-    event.seq > firstStepStart.seq
-    && event.type === 'step/end'
-    && event.data.turn === turnStart.data.turn
-    && event.data.step === firstStepStart.data.step)
-  const firstStepEnd = stepEndIndex === -1 ? events.length : stepEndIndex
-  const openingRangeStart = target.seq < firstStepStart.seq ? turnStart.seq + 1 : firstStepStart.seq + 1
-  const openingRangeEnd = target.seq < firstStepStart.seq ? firstStepStart.seq : firstStepEnd
-  const openingHuman = events.slice(openingRangeStart, openingRangeEnd).findLast(event =>
-    event.type === 'user/message' && event.data.source.kind === 'user')
-  if (openingHuman?.seq !== target.seq) {
-    throw editUnavailable(request, 'the selected message is steering rather than the turn-opening prompt')
-  }
-  const firstSurfaceIndex = currentSurface.findIndex(seq => seq > turnStart.seq && seq <= target.seq)
-  const surfaceEnd = currentSurface.at(-1)
-  /* v8 ignore next -- target membership above guarantees one same-turn surface node and a non-empty surface. */
-  if (firstSurfaceIndex < 0 || surfaceEnd === undefined) {
-    throw editUnavailable(request, 'the selected turn has no current model context')
-  }
-  const shadowedSurfaceSeqs = currentSurface.slice(firstSurfaceIndex)
-  return {
-    event: target,
-    turnStartSeq: turnStart.seq,
-    // The indexed target came from this snapshot, so the final event exists.
-    rawEndSeq: (events.at(-1) as SessionEvent).seq,
-    surfaceStart: shadowedSurfaceSeqs[0] as SessionSeq,
-    surfaceEnd,
-    shadowedSurfaceSeqs: [...shadowedSurfaceSeqs],
-    preservedContexts: events.slice(target.seq + 1, firstStepEnd).filter(isSessionReferenceContext),
-  }
-}
-
-/** Whether one user-role context is the durable recall paired with a prompt. */
-function isSessionReferenceContext(event: SessionEvent): event is SessionEvent<'user/message'> {
-  if (event.type !== 'user/message') return false
-  const source = event.data.source as { readonly kind?: unknown; readonly form?: unknown }
-  return source.kind === 'session-reference' && source.form === 'recall'
-}
-
-/** Replace all text blocks with one edited block while retaining non-text content in place. */
-function replaceMessageText(content: readonly ContentBlock[], text: string): ContentBlock[] {
-  const result: ContentBlock[] = []
-  let foundText = false
-  for (const block of content) {
-    if (block.type !== 'text') {
-      result.push(block)
-      continue
-    }
-    if (foundText) continue
-    foundText = true
-    if (text !== '') result.push({ type: 'text', text })
-  }
-  /* v8 ignore next -- resolveEditTarget requires a text block before calling this helper. */
-  if (!foundText) throw new Error('editable message contains no text block')
-  if (result.length === 0) {
-    throw new RemoteError('gateway/bad-request', 'edited message cannot be empty', {})
-  }
-  return result
-}
-
-/** Build the stable rejection for a message that cannot be edited. */
-function editUnavailable(request: SessionEditRequest, reason: string): RemoteError<'session/edit-unavailable'> {
-  return new RemoteError(
-    'session/edit-unavailable',
-    `session "${request.sessionId}" message ${String(request.messageSeq)} is not editable: ${reason}`,
-    { sessionId: request.sessionId, messageSeq: request.messageSeq },
-  )
-}
-
-/** Wait until the exact edit message commits, or its owning turn closes without it. */
-function waitForEditedMessage(
-  ctx: Context,
-  agent: Agent,
-  messageId: MessageId,
-): { readonly committed: Promise<SessionEvent<'user/message'>>; readonly dispose: () => void } {
-  let claimedTurn: number | undefined
-  const disposers: Array<() => void> = []
-  const dispose = (): void => {
-    for (const disposeListener of disposers.splice(0)) disposeListener()
-  }
-  const promise = new Promise<SessionEvent<'user/message'>>((resolve, reject) => {
-    const finish = (outcome: { event: SessionEvent<'user/message'> } | { error: Error }): void => {
-      dispose()
-      if ('event' in outcome) resolve(outcome.event)
-      else reject(outcome.error)
-    }
-    disposers.push(ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => {
-      if (subject === agent && message.id === messageId) claimedTurn = turn
-    }, { global: true }))
-    disposers.push(ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => {
-      if (subject !== agent || message.id !== messageId) return
-      finish({ error: new EditAdmissionStale('edit message was discarded before it committed') })
-    }, { global: true }))
-    disposers.push(ctx.on('session/event', (session, event) => {
-      if (session !== agent.session) return
-      if (event.type === 'user/message' && event.data.id === messageId) {
-        finish({ event })
-        return
-      }
-      if (claimedTurn !== undefined
-        && event.type === 'turn/end'
-        && event.data.turn === claimedTurn) {
-        finish({ error: new EditAdmissionStale('edit turn ended before the replacement message committed') })
-      }
-    }, { global: true }))
-    disposers.push(ctx.on('agent/disposed', ({ agent: subject }) => {
-      if (subject === agent) {
-        finish({ error: new EditAdmissionStale('edit agent was disposed before the replacement message committed') })
-      }
-    }, { global: true }))
-  })
-  return { committed: promise.finally(dispose), dispose }
-}
-
 function imageBlockIn(
   content: unknown,
   match: (ref: ImageAttachmentRef) => boolean,

+ 2 - 9
packages/api/session-controller/src/history.ts

@@ -4,11 +4,9 @@ import type { Context } from '@deepseek-ai/cordis'
 import { Deque } from '@deepseek-ai/dsh-deque'
 import {
   isAppendSurfaceEvent,
-  isConversationReplacementEvent,
   SessionLogOffset,
   SessionSeq,
 } from '@deepseek-ai/dsh-session'
-import { foldConversation, isConversationSeqVisible } from '@deepseek-ai/dsh-session/conversation'
 import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
 import type {
   SessionEvent,
@@ -324,18 +322,13 @@ function paginate(
   throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1,
 ): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
   const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1))
-  const conversation = foldConversation(events)
   let count = 0
   let cut = SessionLogOffset(0)
   for (let index = end - 1; index >= 0; index--) {
     const event = events[index] as SessionEvent
-    if (!isConversationSeqVisible(event.seq, conversation.hiddenRanges)) continue
-    if (!MESSAGE_TYPES.has(event.type)
-      || (!isAppendSurfaceEvent(event) && !isConversationReplacementEvent(event))) continue
+    if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
     count++
-    const sources = isConversationReplacementEvent(event)
-      ? undefined
-      : event.sourceEventSeqs
+    const sources = event.sourceEventSeqs
     let groupStart = event.seq
     if (sources !== undefined) {
       for (const source of sources) {

+ 0 - 14
packages/api/session-controller/src/index.ts

@@ -34,8 +34,6 @@ import type {
   SessionControlFrame,
   SessionCreateRequest,
   SessionCreateValue,
-  SessionEditRequest,
-  SessionEditValue,
   SessionFollowFrame,
   SessionFollowRequest,
   SessionForkRequest,
@@ -339,18 +337,6 @@ export class SessionController extends TypertRemoteService {
     return this.commands.prompt(request)
   }
 
-  /**
-   * Replace the latest current turn-opening user message and rerun from that point.
-   * @param request - target message, optimistic revision, and replacement text.
-   * @param signal - caller cancellation before the replacement is admitted.
-   * @returns acknowledgement after the replacement message commits.
-   */
-  @Remote('edit')
-  edit(request: SessionEditRequest, signal: AbortSignal): Promise<SessionEditValue> {
-    signal.throwIfAborted()
-    return this.commands.edit(request, signal)
-  }
-
   /**
    * Read one image proven reachable from the addressed Session log.
    * @param request - Session and attachment identities used for authorization.

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

@@ -196,8 +196,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
     'session/steer-unavailable': { readonly itemId: MessageId }
     'session/title-invalid': { readonly sessionId: SessionId }
     'session/fork-unavailable': { readonly sessionId: SessionId }
-    'session/edit-unavailable': { readonly sessionId: SessionId; readonly messageSeq: number }
-    'session/edit-stale': { readonly sessionId: SessionId; readonly messageSeq: number }
     'subagent/not-found': {
       readonly parentSessionId: SessionId
       readonly childSessionId: SessionId
@@ -315,26 +313,6 @@ export interface SessionPromptValue {
   readonly accepted: true
 }
 
-/** Replace the latest current turn-opening user message and rerun from that point. */
-export interface SessionEditRequest {
-  /** Client-minted identity persisted on the replacement user message. */
-  readonly requestId: SessionRequestId
-  readonly sessionId: SessionId
-  /** Exact current user-message event selected for editing. */
-  readonly messageSeq: number
-  /** Latest human user-message seq observed when the editor opened. */
-  readonly expectedLastUserSeq: number
-  /** Replacement text; non-text content is retained from the selected message. */
-  readonly text: string
-  readonly clientTimeZone?: string
-}
-
-/** Receipt after the replacement user message commits to the Session log. */
-export interface SessionEditValue {
-  readonly accepted: true
-  readonly messageSeq: number
-}
-
 /** Durable image read request. */
 export interface SessionAttachmentRequest {
   readonly sessionId: SessionId
@@ -425,13 +403,6 @@ export type SessionWireSurfaceOp =
   | 'append'
   | { readonly op: 'replace'; readonly start: number; readonly end: number }
 
-/** Browser wire form of one user-facing conversation replacement. */
-export type SessionWireConversationOp = {
-  readonly op: 'replace'
-  readonly start: number
-  readonly end: number
-}
-
 /** Event-shaped wire representation of one packed chunk row. */
 export type ChunkRowEvent = {
   [Kind in ChunkRow['type']]: {
@@ -460,7 +431,6 @@ export interface SessionWireEvent {
   readonly ignorable?: true
   readonly sourceEventSeqs?: number[]
   readonly surfaceOp?: SessionWireSurfaceOp
-  readonly conversationOp?: SessionWireConversationOp
 }
 
 /** One message-aligned backwards-history request. */

+ 0 - 5
packages/api/session-controller/tests/fake-api.client.ts

@@ -11,8 +11,6 @@ import type {
   SessionAddress,
   SessionControlBaseline,
   SessionControlFrame,
-  SessionEditRequest,
-  SessionEditValue,
   SessionFollowFrame,
   SessionFollowRequest,
   SessionPage,
@@ -143,8 +141,6 @@ export class FakeApiClient {
     () => Promise.resolve(ok({ records: [], hasMore: false }))
 
   onPrompt: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
-  onEdit: (payload: SessionEditRequest) => Promise<RemoteResult<SessionEditValue>> = () =>
-    Promise.resolve(ok({ accepted: true as const, messageSeq: 0 }))
   onAttachment: (payload: unknown) => Promise<RemoteResult<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
     () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
   onUpdateQueue: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -228,7 +224,6 @@ export class FakeApiClient {
         rename: payload => this.record('session.rename', payload, this.onRename(payload)),
         fork: payload => this.record('session.fork', payload, this.onFork(payload)),
         prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)),
-        edit: payload => this.record('session.edit', payload, this.onEdit(payload)),
         attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)),
         updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
         cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)),

+ 0 - 723
packages/api/session-controller/tests/session-edit.host.spec.ts

@@ -1,723 +0,0 @@
-import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
-import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
-import LlmRuntime, { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
-import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent, SurfaceIntent } from '@deepseek-ai/dsh-session'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ToolRuntime from '@deepseek-ai/dsh-tools'
-import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import { describe, expect, it, vi } from 'vitest'
-import { ApiSessionAgentController } from '../src/agent.ts'
-import { SessionCommandController } from '../src/commands.ts'
-import type { SessionEditRequest, SessionRequestId } from '../src/types.ts'
-import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
-import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
-
-function appendTurn(session: Session, turn: number, prompt: string, answer: string): SessionEvent<'user/message'> {
-  session.append('turn/start', { turn })
-  session.append('step/start', { turn, step: 1 })
-  const user = session.append('user/message', createUserMessage({
-    content: [{ type: 'text', text: prompt }],
-    source: { kind: 'user' },
-  }), { surfaceOp: 'append' })
-  session.append('assistant/message', {
-    turn,
-    step: 1,
-    message: createAssistantMessage({
-      content: [{ type: 'text', text: answer }],
-      source: { provider: 'fixture', model: 'fixture-model' },
-    }),
-  }, { surfaceOp: 'append', sourceEventSeqs: [] })
-  session.append('step/end', { turn, step: 1 })
-  session.append('turn/end', { turn, reason: { kind: 'completed' } })
-  return user
-}
-
-async function harness(
-  running = false,
-  inputModalities: readonly ('text' | 'image')[] | null = ['text', 'image'],
-  providers: readonly string[] = ['fixture'],
-): Promise<{
-  ctx: Context
-  controller: SessionCommandController
-  agent: Agent
-  session: Session
-  sent: Array<{
-    message: Parameters<Agent['send']>[0]
-    followingMessages: readonly Parameters<Agent['send']>[0][]
-    intent: SurfaceIntent | undefined
-    position: string | undefined
-  }>
-  cancel: ReturnType<typeof vi.fn>
-}> {
-  const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  await ctx.plugin(AgentRegistry)
-  installSessionReadTestServices(ctx)
-  ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
-  ctx.provide('llm', {
-    listProviders: () => providers.map(id => ({ id })),
-    resolveModelInfo: () => Promise.resolve(inputModalities === null ? {} : { inputModalities }),
-  } as never)
-  ctx.provide('agentDefaultModel', {
-    currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
-    saveSelection: () => Promise.resolve(),
-  } as never)
-  const session = ctx.sessions.create(SessionId('edit-session'), { meta: { cwd: '/workspace' } })
-  const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
-  let status: Agent['status'] = running ? 'running' : 'idle'
-  let nextTurn = 100
-  const sent: Array<{
-    message: Parameters<Agent['send']>[0]
-    followingMessages: readonly Parameters<Agent['send']>[0][]
-    intent: SurfaceIntent | undefined
-    position: string | undefined
-  }> = []
-  const agent = {
-    id: session.id,
-    options: { provider: 'fixture', model: 'fixture-model' },
-    session,
-    inbox,
-    get status() { return status },
-    ctx,
-    send(message, _target, _wakeup, options) {
-      sent.push({
-        message,
-        followingMessages: options?.followingMessages ?? [],
-        intent: options?.surfaceIntent,
-        position: options?.position,
-      })
-      const turn = nextTurn
-      nextTurn += 1
-      session.append('turn/start', { turn })
-      session.append('step/start', { turn, step: 1 })
-      session.append('user/message', message, options?.surfaceIntent ?? { surfaceOp: 'append' })
-      for (const following of options?.followingMessages ?? []) {
-        session.append('user/message', following, { surfaceOp: 'append' })
-      }
-      session.append('step/end', { turn, step: 1 })
-      session.append('turn/end', { turn, reason: { kind: 'completed' } })
-    },
-    followup: vi.fn(),
-    steer: vi.fn(),
-    inject: vi.fn(),
-    cancel: vi.fn(() => {
-      status = 'idle'
-      ctx.emit('agent/status', { agent, status: 'idle' })
-    }),
-    runMaintenance: async operation => operation(new AbortController().signal),
-    whenIdle: () => Promise.resolve(),
-  } satisfies Agent
-  const cancel = vi.mocked(agent.cancel)
-  ctx.agents.register(agent)
-  const selection: ModelSelectionRef = {
-    current: { provider: 'fixture', model: 'fixture-model' },
-    assembled: undefined,
-  }
-  const agents = {
-    resolveAgent: () => Promise.resolve({ agent }),
-    selectionFor: () => selection,
-    serializeImageAdmission: (_agent: Agent, operation: () => Promise<unknown>) => operation(),
-  } as unknown as ApiSessionAgentController
-  return {
-    ctx,
-    controller: new SessionCommandController(ctx, agents, '/workspace'),
-    agent,
-    session,
-    sent,
-    cancel,
-  }
-}
-
-function request(
-  messageSeq: number,
-  expectedLastUserSeq: number,
-  text = 'edited',
-  clientTimeZone: string | undefined = 'UTC',
-): SessionEditRequest {
-  return {
-    requestId: 'edit-request' as SessionRequestId,
-    sessionId: SessionId('edit-session'),
-    messageSeq,
-    expectedLastUserSeq,
-    text,
-    ...(clientTimeZone === undefined ? {} : { clientTimeZone }),
-  }
-}
-
-describe('session.edit', () => {
-  it.each([false, true])('replaces the latest turn and prioritizes the rerun (running=%s)', async (running) => {
-    const fixture = await harness(running)
-    appendTurn(fixture.session, 1, 'first', 'first answer')
-    const second = appendTurn(fixture.session, 2, 'second', 'second answer')
-    fixture.agent.inbox.append('next-turn', createUserMessage({
-      content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
-    }))
-
-    const result = await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal)
-
-    expect(result).toMatchObject({ accepted: true })
-    expect(fixture.sent).toHaveLength(1)
-    expect(fixture.sent[0]).toMatchObject({
-      message: { content: [{ type: 'text', text: 'edited' }] },
-      position: 'front',
-      intent: {
-        surfaceOp: { op: 'replace', start: second.seq },
-        conversationOp: { op: 'replace', start: second.seq - 2 },
-      },
-    })
-    expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
-      [{ type: 'text', text: 'first' }],
-      [{ type: 'text', text: 'first answer' }],
-      [{ type: 'text', text: 'edited' }],
-    ])
-    expect(fixture.cancel).toHaveBeenCalledTimes(running ? 1 : 0)
-    if (running) expect(fixture.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
-    expect(fixture.agent.inbox.nextTurn.map(message => message.content)).toEqual([
-      [{ type: 'text', text: 'queued' }],
-    ])
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('preserves non-text blocks and permits unchanged text', async () => {
-    const fixture = await harness()
-    fixture.session.append('turn/start', { turn: 1 })
-    fixture.session.append('step/start', { turn: 1, step: 1 })
-    const prompt = fixture.session.append('user/message', createUserMessage({
-      content: [
-        { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
-        { type: 'text', text: 'same' },
-        { type: 'text', text: ' second block' },
-      ],
-      source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    fixture.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'referenced context' }],
-      source: {
-        kind: 'session-reference',
-        form: 'recall',
-        version: 1,
-        references: [],
-      } as never,
-    }), { surfaceOp: 'append' })
-    fixture.session.append('step/end', { turn: 1, step: 1 })
-    fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-
-    await fixture.controller.edit(request(prompt.seq, prompt.seq, 'same second block'), new AbortController().signal)
-
-    expect(fixture.sent[0]?.message.content).toEqual([
-      { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
-      { type: 'text', text: 'same second block' },
-    ])
-    expect(fixture.sent[0]?.followingMessages).toEqual([
-      expect.objectContaining({ content: [{ type: 'text', text: 'referenced context' }] }),
-    ])
-    expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
-      [
-        { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
-        { type: 'text', text: 'same second block' },
-      ],
-      [{ type: 'text', text: 'referenced context' }],
-    ])
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('rejects a preserved image before interrupting a text-only model', async () => {
-    const fixture = await harness(true, ['text'])
-    fixture.session.append('turn/start', { turn: 1 })
-    fixture.session.append('step/start', { turn: 1, step: 1 })
-    const prompt = fixture.session.append('user/message', createUserMessage({
-      content: [
-        { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
-        { type: 'text', text: 'describe' },
-      ],
-      source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-
-    await expect(fixture.controller.edit(
-      request(prompt.seq, prompt.seq, 'describe again'),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'session/attachment-invalid' })
-    expect(fixture.cancel).not.toHaveBeenCalled()
-    expect(fixture.sent).toEqual([])
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('accepts a preserved image when the current model declares no modality list', async () => {
-    const fixture = await harness(false, null)
-    fixture.session.append('turn/start', { turn: 1 })
-    fixture.session.append('step/start', { turn: 1, step: 1 })
-    const prompt = fixture.session.append('user/message', createUserMessage({
-      content: [
-        { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
-        { type: 'text', text: 'describe' },
-      ],
-      source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-
-    await expect(fixture.controller.edit(
-      request(prompt.seq, prompt.seq, 'describe again'),
-      new AbortController().signal,
-    )).resolves.toMatchObject({ accepted: true })
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('rejects invalid timezone and unavailable-model requests before interruption', async () => {
-    const invalidZone = await harness(true)
-    const zoneTarget = appendTurn(invalidZone.session, 1, 'prompt', 'answer')
-    await expect(invalidZone.controller.edit({
-      ...request(zoneTarget.seq, zoneTarget.seq),
-      clientTimeZone: 'not/a-zone',
-    }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/invalid-time-zone' })
-    expect(invalidZone.cancel).not.toHaveBeenCalled()
-    await invalidZone.ctx.fiber.dispose()
-
-    const unavailable = await harness(true, ['text'], [])
-    const modelTarget = appendTurn(unavailable.session, 1, 'prompt', 'answer')
-    await expect(unavailable.controller.edit(
-      request(modelTarget.seq, modelTarget.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'session/model-unavailable' })
-    expect(unavailable.cancel).not.toHaveBeenCalled()
-    await unavailable.ctx.fiber.dispose()
-  })
-
-  it('honors cancellation before resolving the target Session', async () => {
-    const fixture = await harness()
-    const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
-    const controller = new AbortController()
-    controller.abort(new Error('request stopped'))
-
-    await expect(fixture.controller.edit(
-      request(target.seq, target.seq),
-      controller.signal,
-    )).rejects.toThrow('request stopped')
-    expect(fixture.sent).toEqual([])
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('maps an admission race to stale and preserves an unexpected send failure as internal', async () => {
-    const raced = await harness()
-    const racedTarget = appendTurn(raced.session, 1, 'prompt', 'answer')
-    vi.spyOn(raced.agent, 'runMaintenance').mockImplementation(async (operation) => {
-      appendTurn(raced.session, 2, 'newer prompt', 'newer answer')
-      return operation(new AbortController().signal)
-    })
-    await expect(raced.controller.edit(
-      request(racedTarget.seq, racedTarget.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'session/edit-stale' })
-    await raced.ctx.fiber.dispose()
-
-    const failedSend = await harness()
-    const sendTarget = appendTurn(failedSend.session, 1, 'prompt', 'answer')
-    const failure = new Error('send failed')
-    vi.spyOn(failedSend.agent, 'send').mockImplementation(() => { throw failure })
-    await expect(failedSend.controller.edit(
-      request(sendTarget.seq, sendTarget.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'gateway/internal', cause: failure })
-    await failedSend.ctx.fiber.dispose()
-  })
-
-  it.each([new Error('cancel failed'), 'cancel failed'])('preserves a thrown cancellation (%s) as an internal failure', async (reason) => {
-    const fixture = await harness(true)
-    const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
-    vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => { throw reason })
-
-    await expect(fixture.controller.edit(
-      request(target.seq, target.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'gateway/internal' })
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it.each([false, true])('reports an occupied maintenance slot as busy (running=%s)', async (running) => {
-    const fixture = await harness(running)
-    const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
-    vi.spyOn(fixture.agent, 'runMaintenance').mockImplementation(() => { throw new Error('maintenance busy') })
-
-    await expect(fixture.controller.edit(
-      request(target.seq, target.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({
-      code: 'session/agent-busy',
-      details: { reason: 'Error: maintenance busy' },
-    })
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it.each([new Error('request stopped'), 'request stopped'])('honors caller cancellation while waiting for idle (%s)', async (reason) => {
-    const fixture = await harness(true)
-    const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
-    const cancel = vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => {})
-    const controller = new AbortController()
-    const editing = fixture.controller.edit(request(target.seq, target.seq), controller.signal)
-
-    await vi.waitFor(() => { expect(cancel).toHaveBeenCalledOnce() })
-    fixture.ctx.emit('agent/status', { agent: {} as Agent, status: 'idle' })
-    fixture.ctx.emit('agent/status', { agent: fixture.agent, status: 'running' })
-    controller.abort(reason)
-
-    await expect(editing).rejects.toThrow(reason instanceof Error ? reason.message : 'edit request aborted')
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('rejects when the edit message is discarded or its claimed turn closes before commit', async () => {
-    const discarded = await harness()
-    const discardedTarget = appendTurn(discarded.session, 1, 'prompt', 'answer')
-    vi.spyOn(discarded.agent, 'send').mockImplementation((message) => {
-      discarded.ctx.emit('agent/inbox/claimed', { agent: {} as Agent, message, turn: 8 })
-      discarded.ctx.emit('agent/inbox/claimed', {
-        agent: discarded.agent,
-        message: createUserMessage({ content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }),
-        turn: 8,
-      })
-      discarded.ctx.emit('agent/inbox/discarded', { agent: {} as Agent, message })
-      discarded.ctx.emit('agent/inbox/discarded', { agent: discarded.agent, message })
-    })
-    await expect(discarded.controller.edit(
-      request(discardedTarget.seq, discardedTarget.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'session/edit-stale' })
-    await discarded.ctx.fiber.dispose()
-
-    const closed = await harness()
-    const closedTarget = appendTurn(closed.session, 1, 'prompt', 'answer')
-    const unrelatedSession = closed.ctx.sessions.create(SessionId('unrelated-edit-session'))
-    vi.spyOn(closed.agent, 'send').mockImplementation((message) => {
-      closed.ctx.emit('agent/inbox/claimed', { agent: closed.agent, message, turn: 9 })
-      closed.ctx.emit('session/event', unrelatedSession, {
-        type: 'turn/end', seq: SessionSeq(0), time: 0, data: { turn: 9, reason: { kind: 'blocked' } },
-      })
-      closed.session.append('turn/end', { turn: 9, reason: { kind: 'blocked' } })
-    })
-    await expect(closed.controller.edit(
-      request(closedTarget.seq, closedTarget.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'session/edit-stale' })
-    await closed.ctx.fiber.dispose()
-  })
-
-  it('rejects when the Agent is disposed before the replacement commits', async () => {
-    const fixture = await harness()
-    const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
-    vi.spyOn(fixture.agent, 'send').mockImplementation(() => {
-      fixture.ctx.emit('agent/disposed', { agent: {} as Agent })
-      fixture.ctx.emit('agent/disposed', { agent: fixture.agent })
-    })
-
-    await expect(fixture.controller.edit(
-      request(target.seq, target.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'session/edit-stale' })
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('allows the current replacement message to be edited again', async () => {
-    const fixture = await harness()
-    const original = appendTurn(fixture.session, 1, 'original', 'answer')
-    const first = await fixture.controller.edit(
-      request(original.seq, original.seq, 'first edit'),
-      new AbortController().signal,
-    )
-
-    const second = await fixture.controller.edit(
-      request(first.messageSeq, first.messageSeq, 'second edit'),
-      new AbortController().signal,
-    )
-
-    expect(second.messageSeq).toBeGreaterThan(first.messageSeq)
-    expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
-      [{ type: 'text', text: 'second edit' }],
-    ])
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('retains a later compaction checkpoint positioned before the edited turn', async () => {
-    const fixture = await harness()
-    const first = appendTurn(fixture.session, 1, 'first', 'first answer')
-    const second = appendTurn(fixture.session, 2, 'second', 'second answer')
-    const firstAnswer = fixture.session.snapshotEvents().find(event =>
-      event.type === 'assistant/message' && event.data.turn === 1)
-    if (firstAnswer?.type !== 'assistant/message') throw new Error('missing first answer')
-    const checkpoint = fixture.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'summary' }],
-      source: { kind: 'plugin', plugin: 'compact' },
-    }), {
-      surfaceOp: { op: 'replace', start: first.seq, end: firstAnswer.seq },
-      sourceEventSeqs: [first.seq, firstAnswer.seq],
-    })
-
-    await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal)
-
-    expect(fixture.sent[0]?.intent?.surfaceOp).toMatchObject({
-      op: 'replace',
-      start: second.seq,
-    })
-    expect(fixture.session.surface.nodes[0]).toBe(checkpoint.seq)
-    expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([
-      [{ type: 'text', text: 'summary' }],
-      [{ type: 'text', text: 'edited' }],
-    ])
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('accepts legacy turns whose opening user message precedes step/start', async () => {
-    const fixture = await harness()
-    fixture.session.append('turn/start', { turn: 1 })
-    const prompt = fixture.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'legacy prompt' }], source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    fixture.session.append('step/start', { turn: 1, step: 1 })
-    fixture.session.append('step/end', { turn: 1, step: 1 })
-    fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-
-    await expect(fixture.controller.edit(
-      request(prompt.seq, prompt.seq, 'edited legacy prompt', undefined),
-      new AbortController().signal,
-    )).resolves.toMatchObject({ accepted: true })
-    expect(fixture.sent[0]?.message.content).toEqual([{ type: 'text', text: 'edited legacy prompt' }])
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('rejects stale, shadowed, steering, empty, and malformed requests before admission', async () => {
-    const fixture = await harness()
-    const first = appendTurn(fixture.session, 1, 'first', 'first answer')
-    const second = appendTurn(fixture.session, 2, 'second', 'second answer')
-    const expectCode = async (value: Promise<unknown>, code: string): Promise<void> => {
-      await expect(value).rejects.toMatchObject({ code })
-    }
-
-    await expectCode(
-      fixture.controller.edit(request(first.seq, first.seq), new AbortController().signal),
-      'session/edit-stale',
-    )
-    await expectCode(
-      fixture.controller.edit(request(first.seq, second.seq), new AbortController().signal),
-      'session/edit-unavailable',
-    )
-    await expectCode(
-      fixture.controller.edit(request(-1, second.seq), new AbortController().signal),
-      'gateway/bad-request',
-    )
-    await expectCode(
-      fixture.controller.edit(request(first.seq, -1), new AbortController().signal),
-      'gateway/bad-request',
-    )
-    await expectCode(
-      fixture.controller.edit(request(second.seq, second.seq, ''), new AbortController().signal),
-      'gateway/bad-request',
-    )
-
-    const rewritten = fixture.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' },
-    }), {
-      surfaceOp: { op: 'replace', start: first.seq, end: SessionSeq(second.seq + 1) },
-      sourceEventSeqs: [
-        first.seq,
-        SessionSeq(first.seq + 1),
-        second.seq,
-        SessionSeq(second.seq + 1),
-      ],
-      conversationOp: {
-        op: 'replace',
-        start: SessionSeq(first.seq - 2),
-        end: SessionSeq(fixture.session.seq - 1),
-      },
-    })
-    await expectCode(
-      fixture.controller.edit(request(first.seq, rewritten.seq), new AbortController().signal),
-      'session/edit-unavailable',
-    )
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('rejects the latest human message after compaction removes it from the current surface', async () => {
-    const fixture = await harness()
-    const target = appendTurn(fixture.session, 1, 'prompt', 'answer')
-    const answer = fixture.session.snapshotEvents().find(event =>
-      event.type === 'assistant/message' && event.data.turn === 1)
-    if (answer?.type !== 'assistant/message') throw new Error('missing answer')
-    fixture.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'summary' }],
-      source: { kind: 'plugin', plugin: 'compact' },
-    }), {
-      surfaceOp: { op: 'replace', start: target.seq, end: answer.seq },
-      sourceEventSeqs: [target.seq, answer.seq],
-    })
-
-    await expect(fixture.controller.edit(
-      request(target.seq, target.seq),
-      new AbortController().signal,
-    )).rejects.toMatchObject({ code: 'session/edit-unavailable' })
-    await fixture.ctx.fiber.dispose()
-  })
-
-  it('rejects non-human, textless, turnless, steered, and step-less targets', async () => {
-    const expectCode = async (value: Promise<unknown>): Promise<void> => {
-      await expect(value).rejects.toMatchObject({ code: 'session/edit-unavailable' })
-    }
-
-    const nonHuman = await harness()
-    nonHuman.session.append('turn/start', { turn: 1 })
-    nonHuman.session.append('step/start', { turn: 1, step: 1 })
-    const context = nonHuman.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
-    }), { surfaceOp: 'append' })
-    const human = nonHuman.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'human' }], source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    await expectCode(nonHuman.controller.edit(request(context.seq, human.seq), new AbortController().signal))
-    await nonHuman.ctx.fiber.dispose()
-
-    const textless = await harness()
-    textless.session.append('turn/start', { turn: 1 })
-    textless.session.append('step/start', { turn: 1, step: 1 })
-    const image = textless.session.append('user/message', createUserMessage({
-      content: [{ type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }],
-      source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    await expectCode(textless.controller.edit(request(image.seq, image.seq), new AbortController().signal))
-    await textless.ctx.fiber.dispose()
-
-    const turnless = await harness()
-    const lone = turnless.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'lone' }], source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    await expectCode(turnless.controller.edit(request(lone.seq, lone.seq), new AbortController().signal))
-    await turnless.ctx.fiber.dispose()
-
-    const stepLess = await harness()
-    stepLess.session.append('turn/start', { turn: 1 })
-    const noStep = stepLess.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'no step' }], source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    await expectCode(stepLess.controller.edit(request(noStep.seq, noStep.seq), new AbortController().signal))
-    await stepLess.ctx.fiber.dispose()
-
-    const steered = await harness()
-    const opening = appendTurn(steered.session, 1, 'opening', 'answer')
-    steered.session.append('turn/start', { turn: 2 })
-    steered.session.append('step/start', { turn: 2, step: 1 })
-    const current = steered.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'current' }], source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    steered.session.append('step/end', { turn: 2, step: 1 })
-    steered.session.append('step/start', { turn: 2, step: 2 })
-    const steering = steered.session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'steering' }], source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    await expectCode(steered.controller.edit(request(steering.seq, steering.seq), new AbortController().signal))
-    expect(opening.seq).toBeLessThan(current.seq)
-    await steered.ctx.fiber.dispose()
-  })
-})
-
-describe('session.edit with AgentLoop', () => {
-  it('runs the edited turn before preserved Queue work through the real loop', async () => {
-    const ctx = new Context()
-    await ctx.plugin(LlmRuntime)
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    await ctx.plugin(SystemPrompt, { persona: '' })
-    await ctx.plugin(ToolRuntime)
-    await ctx.plugin(AgentRegistry)
-    await ctx.plugin(AgentLoop, { agents: [] })
-    ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
-    const adapter = new MockAdapter([
-      textResponse('old answer'),
-      textResponse('edited answer'),
-      textResponse('queued answer'),
-    ])
-    ctx.llm.registerAdapter(['fixture'], adapter)
-    const remote = createSessionTestRemote(ctx, {
-      defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
-      cwd: '/workspace',
-    })
-    const agent = await ctx.agentLoop.create(SessionId('real-edit'), {
-      provider: 'fixture', model: 'fixture-model',
-    })
-    agent.followup(createUserMessage({
-      content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
-    }))
-    await agent.whenIdle()
-    const target = agent.session.snapshotEvents().find(event => event.type === 'user/message')
-    if (target?.type !== 'user/message') throw new Error('missing original prompt')
-    agent.inbox.append('next-turn', createUserMessage({
-      content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
-    }))
-
-    const result = await remote.edit({
-      requestId: 'real-edit-request' as SessionRequestId,
-      sessionId: agent.id,
-      messageSeq: target.seq,
-      expectedLastUserSeq: target.seq,
-      text: 'edited',
-      clientTimeZone: 'UTC',
-    })
-    expect(result).toMatchObject({ ok: true, value: { accepted: true } })
-    await agent.whenIdle()
-
-    const requests = adapter.requests.map(request => JSON.stringify(request.messages))
-    expect(requests).toHaveLength(3)
-    expect(requests[1]).toContain('edited')
-    expect(requests[1]).not.toContain('original')
-    expect(requests[1]).not.toContain('queued')
-    expect(requests[2]).toContain('queued')
-    expect(agent.session.deriveMessages().map(message => message.content)).toEqual([
-      [{ type: 'text', text: 'edited' }],
-      [{ type: 'text', text: 'edited answer' }],
-      [{ type: 'text', text: 'queued' }],
-      [{ type: 'text', text: 'queued answer' }],
-    ])
-    await ctx.fiber.dispose()
-  })
-
-  it('interrupts a running turn before admitting the edited rerun', async () => {
-    const ctx = new Context()
-    await ctx.plugin(LlmRuntime)
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    await ctx.plugin(SystemPrompt, { persona: '' })
-    await ctx.plugin(ToolRuntime)
-    await ctx.plugin(AgentRegistry)
-    await ctx.plugin(AgentLoop, { agents: [] })
-    ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
-    const adapter = new MockAdapter(['hang', textResponse('edited answer')])
-    ctx.llm.registerAdapter(['fixture'], adapter)
-    const remote = createSessionTestRemote(ctx, {
-      defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
-      cwd: '/workspace',
-    })
-    const agent = await ctx.agentLoop.create(SessionId('running-edit'), {
-      provider: 'fixture', model: 'fixture-model',
-    })
-    agent.followup(createUserMessage({
-      content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
-    }))
-    await vi.waitFor(() => {
-      expect(adapter.requests).toHaveLength(1)
-    })
-    const target = agent.session.snapshotEvents().find(event => event.type === 'user/message')
-    if (target?.type !== 'user/message') throw new Error('missing running prompt')
-
-    const result = await remote.edit({
-      requestId: 'running-edit-request' as SessionRequestId,
-      sessionId: agent.id,
-      messageSeq: target.seq,
-      expectedLastUserSeq: target.seq,
-      text: 'edited while running',
-    })
-    expect(result).toMatchObject({ ok: true })
-    await agent.whenIdle()
-
-    expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end').map(event =>
-      event.type === 'turn/end' ? event.data.reason.kind : '')).toEqual(['aborted', 'completed'])
-    expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('edited while running')
-    expect(JSON.stringify(adapter.requests[1]?.messages)).not.toContain('original')
-    await ctx.fiber.dispose()
-  })
-})

+ 0 - 45
packages/api/session-controller/tests/session-history-journal.host.spec.ts

@@ -254,51 +254,6 @@ describe('Session history raw journal', () => {
     expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
   })
 
-  it('counts an edit replacement as the current message without pulling its hidden tail into the page', async () => {
-    const { ctx } = await harness()
-    const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
-    const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
-    const firstStart = session.append('turn/start', { turn: 1 })
-    const first = appendUserText(session, 'first prompt')
-    appendAssistantText(session, 'first reply', 1)
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-    session.append('turn/start', { turn: 2 })
-    const second = appendUserText(session, 'second prompt')
-    const secondAnswer = appendAssistantText(session, 'second reply', 2)
-    const secondEnd = session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
-    const shadowed = session.surface.nodes.slice(session.surface.nodes.indexOf(first.seq))
-    session.append('turn/start', { turn: 3 })
-    session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'edited prompt' }], source: { kind: 'user' },
-    }), {
-      surfaceOp: { op: 'replace', start: first.seq, end: secondAnswer.seq },
-      sourceEventSeqs: [...shadowed],
-      conversationOp: { op: 'replace', start: firstStart.seq, end: secondEnd.seq },
-    })
-    const editedAnswer = appendAssistantText(session, 'edited reply', 3)
-
-    const response = await remote.page({
-      address: { kind: 'session', sessionId: session.id },
-      throughSeq: session.seq - 1,
-      maxMessages: 2,
-    })
-    if (!response.ok) throw new Error('unreachable')
-    const messages = pageEvents(response.value)
-      .filter(event => event.type === 'user/message' || event.type === 'assistant/message')
-    expect(messages.map(event => event.seq)).toEqual([editedAnswer.seq - 1, editedAnswer.seq])
-    expect(messages.some(event => event.seq === second.seq)).toBe(false)
-    expect(response.value.hasMore).toBe(true)
-
-    const expanded = await remote.page({
-      address: { kind: 'session', sessionId: session.id },
-      throughSeq: session.seq - 1,
-      maxMessages: 3,
-    })
-    if (!expanded.ok) throw new Error('unreachable')
-    expect(pageEvents(expanded.value).some(event => event.seq === second.seq)).toBe(true)
-    expect(expanded.value.hasMore).toBe(false)
-  })
-
   it('paginates a message with many provenance sources without variadic argument expansion', async () => {
     const { ctx } = await harness()
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })

+ 2 - 59
packages/api/session-controller/tests/session-pending-submissions.client.spec.ts

@@ -4,11 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types'
-import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+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 { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
+import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
 import { historyValue } from './event-script.client.ts'
 
 const SID = 'fk-s1' as SessionId
@@ -254,63 +254,6 @@ describe('observed retirement', () => {
   })
 })
 
-describe('pending edit', () => {
-  it('publishes optimistic state immediately and retires it after the durable replacement', async () => {
-    const { api, session } = makeSession()
-    api.onHistory = () => Promise.resolve(ok(historyValue([])))
-    await session.open()
-    const response = deferred<RemoteResult<{ accepted: true; messageSeq: number }>>()
-    api.onEdit = () => response.promise
-
-    const editing = session.edit(10, 12, 'edited')
-    const pending = session.getSnapshot().pendingEdit
-    expect(pending).toMatchObject({ targetSeq: 10, expectedLastUserSeq: 12, text: 'edited' })
-    const request = api.callsOf('session.edit')[0] as { requestId: SessionRequestId }
-    await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), request.requestId) as never })
-    await settleFrames()
-    expect(session.getSnapshot().pendingEdit).toBeNull()
-
-    response.resolve(ok({ accepted: true, messageSeq: 0 }))
-    await expect(editing).resolves.toEqual(ok({ accepted: true, messageSeq: 0 }))
-  })
-
-  it('restores the durable view and exposes promptError when admission fails', async () => {
-    const { api, session } = makeSession()
-    api.onEdit = () => Promise.resolve(err(new RemoteError(
-      'session/edit-stale',
-      'stale',
-      { sessionId: SID, messageSeq: 10 },
-    )))
-
-    const result = await session.edit(10, 12, 'edited')
-
-    expect(result.ok).toBe(false)
-    expect(session.getSnapshot().pendingEdit).toBeNull()
-    expect(session.getSnapshot().promptError).toMatchObject({ op: 'edit' })
-  })
-
-  it('normalizes a thrown Remote failure and rethrows an unexpected Client fault', async () => {
-    const remoteFailure = new RemoteError('gateway/internal', 'connection failed', {})
-    const failed = makeSession()
-    failed.api.onEdit = () => Promise.reject(remoteFailure)
-
-    await expect(failed.session.edit(10, 12, 'edited')).resolves.toEqual({
-      ok: false,
-      error: remoteFailure,
-    })
-    expect(failed.session.getSnapshot()).toMatchObject({
-      pendingEdit: null,
-      promptError: { op: 'edit', error: remoteFailure },
-    })
-
-    const faulted = makeSession()
-    const fault = new Error('client assembly failed')
-    faulted.api.onEdit = () => Promise.reject(fault)
-    await expect(faulted.session.edit(10, 12, 'edited')).rejects.toBe(fault)
-    expect(faulted.session.getSnapshot()).toMatchObject({ pendingEdit: null, promptError: null })
-  })
-})
-
 describe('disposal', () => {
   it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => {
     const { api, session } = makeSession()

+ 0 - 7
packages/api/session-controller/tests/test-remote.ts

@@ -34,8 +34,6 @@ import type {
   SessionControlFrame,
   SessionCreateRequest,
   SessionCreateValue,
-  SessionEditRequest,
-  SessionEditValue,
   SessionForkRequest,
   SessionForkValue,
   SessionFollowFrame,
@@ -69,7 +67,6 @@ export interface TestSessionRemote {
   rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
   fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
   prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPromptValue>>
-  edit(request: SessionEditRequest, signal?: AbortSignal): Promise<RemoteResult<SessionEditValue>>
   attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
   updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
   cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
@@ -319,10 +316,6 @@ export function createSessionTestRemote(
       () => direct.prompt(request, signal),
       signal,
     ),
-    edit: (request, signal = new AbortController().signal) => remoteResult(
-      () => direct.edit(request, signal),
-      signal,
-    ),
     attachment: request => remoteResult(() => direct.attachment(request)),
     updateQueue: request => remoteResult(() => direct.updateQueue(request)),
     cancel: request => remoteResult(() => direct.cancel(request)),

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: 00943283c1f0d52b684c67240bb8f7b8d861f091
-README.zh.md: 542563eb63b6fe2dd7a945268a7d834ee50652fe
+README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde
+README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc

+ 1 - 11
packages/client/ui-chat/README.md

@@ -8,12 +8,11 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the destination selected when submission begins: transcript echoes render at the flow tail, steering echoes carry the pending marker, and queued echoes stay out of Chat. Each echo disappears in the same render that exposes its durable user/steering node or Queue occurrence. `SessionSnapshot.pendingEdit` similarly replaces the selected suffix until the durable edited generation is renderable.
+The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic.
 
 ## Table of Contents
 
 - [System prompt row](#system-prompt-row)
-- [Message editing](#message-editing)
 - [Turn token usage](#turn-token-usage)
 - [Turn Process Folding](#turn-process-folding)
 - [Scroll ownership](#scroll-ownership)
@@ -30,15 +29,6 @@ Chat shows a collapsed `System prompt` row for each non-empty initial or resumed
 
 -----
 
-<a id="message-editing"></a>
-## Message editing
-
-Only the latest human message exposes Edit, and only when it is a current-surface, turn-opening prompt in an ordinary Session. Earlier messages, compacted messages, steering, injected context, and direct subagent conversations do not. Edit replaces the bubble with a composer-style input card that fills the Chat content width: input-surface fill, border and elevation, plus an internal cancel/save action row. Its textarea starts at 80px, grows with its content up to 240px, and then scrolls internally. Enter submits, Shift+Enter inserts a newline, and Escape cancels. The normal composer remains enabled; starting another submission closes the inline editor.
-
-Submission moves the edited message to the transcript tail immediately and hides every later row while the Host validates and commits the replacement. A failure restores the durable conversation and appears through the normal prompt-error channel. A successful replacement has a new timestamp and no edited badge or Undo action; its retained attachments and session-reference labels remain on the replacement. The existing Session title is unchanged.
-
------
-
 <a id="turn-token-usage"></a>
 ## Turn token usage
 

+ 1 - 11
packages/client/ui-chat/README.zh.md

@@ -8,12 +8,11 @@ kind: "package-reference"
 
 ## 概述
 
-Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的目标:transcript 回显位于消息流末尾,steering 回显带 pending 标记,queued 回显不进入 Chat。每条回显都在同一次渲染中由其持久 user/steering 节点或 Queue occurrence 接替。`SessionSnapshot.pendingEdit` 同样会在持久编辑代次可渲染前替换选中的后缀
+Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的
 
 ## 目录
 
 - [系统提示词行](#system-prompt-row)
-- [消息编辑](#message-editing)
 - [轮次 token 用量](#turn-token-usage)
 - [轮次过程折叠](#turn-process-folding)
 - [滚动归属](#scroll-ownership)
@@ -30,15 +29,6 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真
 
 -----
 
-<a id="message-editing"></a>
-## 消息编辑
-
-只有最新的人工消息会显示「编辑消息」,且它必须是普通 Session 中仍位于当前 surface 的轮次开场提示词。更早的消息、被压缩的消息、steering、注入上下文和 direct subagent 对话都不显示该动作。编辑会把气泡替换为铺满 Chat 内容列的 composer 风格输入卡片:使用输入表面背景、边框和阴影,并在卡片内部放置取消/保存操作行。文本框初始高度为 80px,随内容增长至 240px 后改为内部滚动。Enter 提交,Shift+Enter 插入换行,Escape 取消。普通 composer 保持可用;开始另一条提交会关闭行内编辑器。
-
-提交后,编辑消息会立即移到 transcript 末尾,并在 Host 校验和提交替换期间隐藏其后的所有行。失败会恢复持久对话,并通过常规 prompt 错误通道展示。成功的替换使用新时间戳,不显示已编辑标记,也不提供 Undo;保留的附件和会话引用标签继续显示在替换消息上。已有 Session 标题保持不变。
-
------
-
 <a id="turn-token-usage"></a>
 ## 轮次 token 用量
 

+ 0 - 4
packages/client/ui-chat/src/client/apply.ts

@@ -146,10 +146,6 @@ export function apply(ctx: Context): void {
                 // Fork or child-title failure leaves the source view unchanged.
               })
           },
-          editMessage: async (seq, expectedLastUserSeq, text) => {
-            const result = await session.edit(seq, expectedLastUserSeq, text)
-            if (!result.ok) throw new Error(`message edit failed: ${result.error.code}: ${result.error.message}`)
-          },
         }
       },
     }, ChatView)

+ 2 - 3
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -37,7 +37,7 @@ function turnOf(node: ChatNode | undefined): number | undefined {
 /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
 export const ChatNodeSeat = memo(function ChatNodeSeat({
   nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript,
-  selectedCallId, cwd, openFile, inspectCall, forkAt, messageEdit,
+  selectedCallId, cwd, openFile, inspectCall, forkAt,
   loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
   const node = useChatNode(nodeKey)
@@ -109,13 +109,12 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
       inspectCall,
       forkAt,
       loadImage,
-      messageEdit,
       renderMessageImages,
       fileMentions,
       turnProcess,
     }, [
     node, selectedCallId, cwd, openFile, inspectCall, forkAt,
-    loadImage, messageEdit, renderMessageImages, fileMentions, turnProcess,
+    loadImage, renderMessageImages, fileMentions, turnProcess,
   ])
   if (routedNode === undefined || owner === null) return null
   const turnData = turnDataOf(routedNode)

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

@@ -7,10 +7,9 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ChatViewSlotProps, MessageEditController } from '../contract/slots.ts'
-import type { ChatNode } from '../contract/chat-nodes.ts'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
 import type { ChatSnapshot } from '../contract/snapshot.ts'
-import { PendingEditBubble, PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
+import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
 import { ChatNodeSeat } from './ChatNodeSeat.tsx'
 import { TurnNavigator } from './TurnNavigator.tsx'
 import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts'
@@ -218,17 +217,10 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
 export function ChatView({
   useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
   sessionId, openFile, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
-  editMessage, useTranscriptView, useProjection, t,
+  useTranscriptView, useProjection, t,
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
   const nodeStore = useChat(s => s.nodes)
-  const latestUserSeq = useChat((snapshot) => {
-    for (let index = snapshot.order.length - 1; index >= 0; index -= 1) {
-      const node = snapshot.nodes.get(snapshot.order[index] ?? '') as ChatNode | undefined
-      if (node?.kind === 'user' || node?.kind === 'steering') return node.data.seq
-    }
-    return -1
-  })
   // The rail's items are accumulated in the Chat snapshot, so this selector is
   // both the data and its change signal: the array identity moves only when a
   // Turn enters, leaves, or changes its preview.
@@ -242,9 +234,6 @@ export function ChatView({
   )
   const timeline = useChat(s => s.timeline)
   const inbox = useSession(s => s.queue)
-  const pendingSubmissions = useSession(s => s.pendingSubmissions)
-  const pendingEdit = useSession(s => s.pendingEdit)
-  const ordinarySession = useSession(s => s.subagent === null)
   // 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)
@@ -259,53 +248,6 @@ export function ChatView({
   }, [openView])
   const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
   const [fileOpenBusy, setFileOpenBusy] = useState(false)
-  const [editingMessage, setEditingMessage] = useState<{
-    seq: number
-    text: string
-    expectedLastUserSeq: number
-  } | null>(null)
-  const previousSubmissionIds = useRef(
-    new Set(pendingSubmissions.map(submission => submission.requestId)),
-  )
-  useEffect(() => {
-    const previous = previousSubmissionIds.current
-    const next = new Set(pendingSubmissions.map(submission => submission.requestId))
-    previousSubmissionIds.current = next
-    if (pendingSubmissions.some(submission => !previous.has(submission.requestId))) {
-      setEditingMessage(null)
-    }
-  }, [pendingSubmissions])
-  const messageEdit = useMemo<MessageEditController>(() => ({
-    latestUserSeq,
-    current: editingMessage,
-    begin: (seq, text, expectedLastUserSeq) => {
-      setEditingMessage({ seq, text, expectedLastUserSeq })
-    },
-    change: (text) => { setEditingMessage(current => current === null ? null : { ...current, text }) },
-    submit: () => {
-      const current = editingMessage
-      if (current === null || editMessage === undefined) return
-      setEditingMessage(null)
-      void editMessage(current.seq, current.expectedLastUserSeq, current.text).catch((error: unknown) => {
-        // Session business and carrier failures render through promptError; only an unexpected Client fault reaches here.
-        console.error('[ui-chat] message edit failed:', error)
-      })
-    },
-    cancel: () => { setEditingMessage(null) },
-  }), [editMessage, editingMessage, latestUserSeq])
-  const messageEditController = editMessage === undefined || !ordinarySession || pendingEdit !== null
-    ? undefined
-    : messageEdit
-  const pendingEditTarget = pendingEdit === null
-    ? undefined
-    : nodeStore.values().find((node): node is ChatNode<'user'> => {
-      const candidate = node as ChatNode
-      return candidate.kind === 'user' && candidate.data.seq === pendingEdit.targetSeq
-    })
-  const pendingEditIndex = pendingEditTarget === undefined
-    ? -1
-    : order.indexOf(pendingEditTarget.key)
-  const displayedOrder = pendingEditIndex < 0 ? order : order.slice(0, pendingEditIndex)
   // Close/retry must ignore a settlement that started before the latest
   // gesture; otherwise a cancelled in-flight refusal reopens the dialog.
   const fileOpenRequest = useRef(0)
@@ -343,6 +285,7 @@ export function ChatView({
     () => inbox.filter(item => item.placement === 'steering'),
     [inbox],
   )
+  const pendingSubmissions = useSession(s => s.pendingSubmissions)
   // Submission echoes still awaiting their durable counterpart. `order` is the
   // recompute trigger: durable user material always arrives as an append, and
   // every append replaces the order array.
@@ -832,7 +775,7 @@ export function ChatView({
             </div>
           )}
           <ChatNodeList
-            order={displayedOrder}
+            order={order}
             useChatNode={useChatNode}
             useChatNodeProcess={useChatNodeProcess}
             historyIncomplete={hasMore}
@@ -845,24 +788,11 @@ export function ChatView({
             inspectCall={inspectCall}
             forkAt={forkAt}
             loadImage={loadImage}
-            messageEdit={messageEditController}
             renderMessageImages={renderMessageImages}
             fileMentions={fileMentions}
             renderSlot={renderSlot}
             t={t}
           />
-          {pendingEdit !== null && pendingEditTarget !== undefined && (
-            <PendingEditBubble
-              content={pendingEditTarget.data.content}
-              text={pendingEdit.text}
-              time={pendingEdit.time}
-              {...pendingEditTarget.data.referenceLabels === undefined
-                ? {}
-                : { referenceLabels: pendingEditTarget.data.referenceLabels }}
-              renderMessageImages={renderMessageImages}
-              t={t}
-            />
-          )}
           {/* No pending placeholders: questions (ui-user-questions) and approvals
               (ApprovalPanel) both take over the composer, so a flow card would
               double-render the same wait. */}

+ 4 - 13
packages/client/ui-chat/src/client/chat/MessageIconActions.tsx

@@ -3,7 +3,7 @@
 
 import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
 import {
-  IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, IconEditOutline16, Tooltip, writeClipboard,
+  IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
 import { formatMessageClock } from './message-chrome.ts'
@@ -19,8 +19,6 @@ export interface MessageIconActionsProps {
   clock: 'start' | 'end'
   /** Fork the session at this message; omission hides the branch action. */
   onBranch?: (() => void) | undefined
-  /** Edit the latest eligible human message in the current Session. */
-  onEdit?: (() => void) | undefined
   /** The message is not a completed transcript tail, so branch stays visible but unavailable. */
   branchUnavailable?: boolean | undefined
   /** Parent layout class composed onto the actions row. */
@@ -40,12 +38,12 @@ export interface MessageIconActionsProps {
 }
 
 /**
- * Copy, optional edit/branch, and clock actions shared by message chrome.
- * @param props - Copy text, event time, clock side, optional actions, and styling.
+ * Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
+ * @param props - Copy text, event time, clock side, branch callback, className.
  * @returns The actions row element.
  */
 export function MessageIconActions({
-  text, time, clock, onBranch, onEdit, branchUnavailable = false, className,
+  text, time, clock, onBranch, branchUnavailable = false, className,
   extraActions, usageAction, t,
 }: MessageIconActionsProps) {
   const day = useCalendarDay()
@@ -89,13 +87,6 @@ export function MessageIconActions({
           {copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
         </button>
       </Tooltip>
-      {onEdit !== undefined && (
-        <Tooltip label={t('message.edit')} side="bottom">
-          <button type="button" className={css.action} aria-label={t('message.edit')} onClick={onEdit}>
-            <IconEditOutline16 />
-          </button>
-        </Tooltip>
-      )}
       {extraActions}
       {onBranch !== undefined && (
         <Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">

+ 0 - 88
packages/client/ui-chat/src/client/chat/MessageItem.module.css

@@ -40,94 +40,6 @@
   word-break: break-word;
 }
 
-.editBubble {
-  box-sizing: border-box;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  width: 100%;
-  padding: 10px 10px 10px 16px;
-  border: 0;
-  --dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
-  background: var(--dsw-specific-input-major);
-  box-shadow: var(--dsw-elevation-soft);
-  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
-  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
-}
-
-.editStack {
-  width: 100%;
-  max-width: 100%;
-}
-
-.editInput {
-  box-sizing: border-box;
-  display: block;
-  width: 100%;
-  min-height: 80px;
-  max-height: 240px;
-  padding: 0 6px 0 0;
-  resize: none;
-  border: none;
-  outline: none;
-  background: transparent;
-  color: inherit;
-  caret-color: var(--dsw-alias-state-business-primary);
-  font: inherit;
-  line-height: inherit;
-  white-space: pre-wrap;
-}
-
-.editToolbar {
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-  gap: 8px;
-}
-
-.actions {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.editAction {
-  display: grid;
-  place-items: center;
-  width: 28px;
-  height: 28px;
-  padding: 0;
-  border: none;
-  border-radius: 999px;
-  corner-shape: round;
-  background: transparent;
-  color: var(--dsw-alias-label-tertiary);
-  cursor: pointer;
-}
-
-.editAction:hover {
-  background: var(--dsw-alias-interactive-bg-hover);
-}
-
-.editAction:focus-visible {
-  outline: 2px solid var(--dsw-alias-label-tertiary);
-  outline-offset: -2px;
-}
-
-.editSaveAction {
-  background: var(--dsw-alias-button-info-fill);
-  color: #fff;
-}
-
-.editSaveAction:hover {
-  background: var(--dsw-alias-button-info-hover);
-}
-
-.editSaveAction:focus-visible {
-  outline-color: var(--dsw-alias-state-business-primary);
-  outline-offset: 2px;
-}
-
 .referenceSummary {
   color: var(--dsw-alias-label-tertiary);
   font-size: var(--dsh-content-font-size-secondary, 13px);

+ 4 - 173
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -1,10 +1,8 @@
-import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { memo, useEffect, useMemo, useState } from 'react'
 import type { ReactNode } from 'react'
 import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import {
-  IconCheckOutline16, IconCloseOutline16, JsonBlock, projectUserText, StateDot, Tooltip,
-} from '@deepseek-ai/dsh-client-ui-primitives'
+import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
 import { CompactionItem } from './CompactionItem.tsx'
@@ -14,18 +12,6 @@ import css from './MessageItem.module.css'
 
 type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
 
-const MESSAGE_EDIT_MAX_HEIGHT = 240
-const MESSAGE_EDIT_INITIAL_HEIGHT = 80
-
-const NO_MESSAGE_EDIT: NonNullable<ChatNodeOwnerProps['messageEdit']> = {
-  latestUserSeq: -1,
-  current: null,
-  begin: () => {},
-  change: () => {},
-  submit: () => {},
-  cancel: () => {},
-}
-
 function contentParts(content: readonly unknown[]): {
   text: string
   images: { attachment: UserImage['attachment'] }[]
@@ -45,22 +31,6 @@ function contentParts(content: readonly unknown[]): {
   return { text: texts.join(''), images, rest }
 }
 
-function replaceTextContent(content: readonly unknown[], text: string): unknown[] {
-  let replaced = false
-  const result: unknown[] = []
-  for (const block of content) {
-    const candidate = block as { type?: unknown }
-    if (candidate.type !== 'text') {
-      result.push(block)
-      continue
-    }
-    if (replaced) continue
-    replaced = true
-    if (text !== '') result.push({ type: 'text', text })
-  }
-  return result
-}
-
 function retrySeconds(milliseconds: number): number {
   return Math.max(1, Math.ceil(milliseconds / 1_000))
 }
@@ -178,7 +148,7 @@ function TurnMaxTokensItem({ t }: {
 
 /** Right-aligned bubble shared by user and steering rows. */
 function UserStyleBubble({
-  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t,
+  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
 }: {
   content: readonly unknown[]
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -192,8 +162,6 @@ function UserStyleBubble({
   referenceLabels?: readonly string[]
   /** Local submission-echo previews replacing the content-derived image group. */
   previewImages?: readonly MessageImageSource[]
-  /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown. */
-  reveal?: 'always' | 'hover'
   t: ChatViewSlotProps['t']
 }): ReactNode {
   const { text, images: contentImages, rest } = contentParts(content)
@@ -205,7 +173,6 @@ function UserStyleBubble({
       className={css.userRow}
       data-pending-steering={pending || undefined}
       data-submission-echo={echo || undefined}
-      data-actions-reveal={reveal}
     >
       <div className={css.userStack}>
         {renderMessageImages({ images, align: 'end' })}
@@ -302,149 +269,16 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
   )
 }
 
-/** Optimistic replacement shown from Edit submit until its durable message lands. */
-export function PendingEditBubble({ content, text, time, referenceLabels = [], renderMessageImages, t }: {
-  content: readonly unknown[]
-  text: string
-  time: number
-  referenceLabels?: readonly string[]
-  renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
-  t: ChatViewSlotProps['t']
-}): ReactNode {
-  const replacement = useMemo(() => replaceTextContent(content, text), [content, text])
-  return (
-    <UserStyleBubble
-      content={replacement}
-      referenceLabels={referenceLabels}
-      renderMessageImages={renderMessageImages}
-      echo
-      t={t}
-      actions={copyText => (
-        <MessageIconActions
-          text={copyText}
-          time={time}
-          clock="start"
-          className={css.actions}
-          t={t}
-        />
-      )}
-    />
-  )
-}
-
-function EditingUserBubble({ content, text, referenceLabels = [], renderMessageImages, change, submit, cancel, t }: {
-  content: readonly unknown[]
-  text: string
-  referenceLabels?: readonly string[]
-  renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
-  change: (text: string) => void
-  submit: () => void
-  cancel: () => void
-  t: ChatViewSlotProps['t']
-}): ReactNode {
-  const { images, rest } = contentParts(content)
-  const inputRef = useRef<HTMLTextAreaElement>(null)
-  useLayoutEffect(() => {
-    const input = inputRef.current
-    if (input === null) return
-    input.style.height = '0px'
-    const height = Math.min(MESSAGE_EDIT_MAX_HEIGHT, Math.max(input.scrollHeight, MESSAGE_EDIT_INITIAL_HEIGHT))
-    input.style.height = `${String(height)}px`
-    input.style.overflowY = input.scrollHeight > MESSAGE_EDIT_MAX_HEIGHT ? 'auto' : 'hidden'
-  }, [text])
-  return (
-    <div className={css.userRow} data-message-edit="">
-      <div className={`${css.userStack} ${css.editStack}`}>
-        {renderMessageImages({ images, align: 'end' })}
-        <div className={`${css.bubble} ${css.editBubble}`}>
-          <textarea
-            ref={inputRef}
-            autoFocus
-            className={css.editInput}
-            aria-label={t('message.edit')}
-            value={text}
-            onChange={(event) => { change(event.currentTarget.value) }}
-            onKeyDown={(event) => {
-              if (event.key === 'Escape') {
-                event.preventDefault()
-                cancel()
-              } else if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
-                event.preventDefault()
-                submit()
-              }
-            }}
-          />
-          {rest.map((block, index) => (
-            <JsonBlock key={index} label={t('message.extraBlock')} payload={block} truncatedLabel={total => t('json.truncated', { total })} />
-          ))}
-          <div className={css.editToolbar}>
-            <Tooltip label={t('message.cancelEdit')} side="bottom">
-              <button type="button" className={css.editAction} aria-label={t('message.cancelEdit')} onClick={cancel}>
-                <IconCloseOutline16 />
-              </button>
-            </Tooltip>
-            <Tooltip label={t('message.saveAndRegenerate')} side="bottom">
-              <button
-                type="button"
-                className={`${css.editAction} ${css.editSaveAction}`}
-                aria-label={t('message.saveAndRegenerate')}
-                onClick={submit}
-              >
-                <IconCheckOutline16 />
-              </button>
-            </Tooltip>
-          </div>
-        </div>
-        {referenceLabels.length > 0 && (
-          <div className={css.referenceSummary}>
-            {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })}
-          </div>
-        )}
-      </div>
-    </div>
-  )
-}
-
 /** User and admitted-steering keyed Chat renderer. */
 export const UserMessageNodeView = memo(function UserMessageNodeView({
-  node, renderMessageImages, useChat, messageEdit, t,
+  node, renderMessageImages, t,
 }: ChatNodeViewProps<'user' | 'steering'>) {
   const data = node.data
-  const edit = messageEdit ?? NO_MESSAGE_EDIT
-  const onCurrentSurface = useChat(snapshot => snapshot.currentSurfaceSeqs?.has(data.seq) === true)
-  // The transcript's last user-authored row keeps its actions row shown, the
-  // same recency gate turn tails use; earlier rows reveal on hover.
-  const isLatestUserRow = useChat((snapshot) => {
-    for (let index = snapshot.order.length - 1; index >= 0; index -= 1) {
-      const candidate = snapshot.nodes.get(snapshot.order[index] ?? '')
-      if (candidate?.kind === 'user' || candidate?.kind === 'steering') return candidate.key === node.key
-    }
-    return true
-  })
-  const editable = messageEdit !== undefined
-    && node.kind === 'user'
-    && onCurrentSurface
-    && data.seq === edit.latestUserSeq
-  if (editable && edit.current?.seq === data.seq) {
-    return (
-      <EditingUserBubble
-        content={data.content}
-        text={edit.current.text}
-        {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
-        renderMessageImages={renderMessageImages}
-        change={edit.change}
-        submit={edit.submit}
-        cancel={edit.cancel}
-        t={t}
-      />
-    )
-  }
   return (
     <UserStyleBubble
       content={data.content}
       renderMessageImages={renderMessageImages}
       {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
-      reveal={isLatestUserRow ? 'always' : 'hover'}
       t={t}
       actions={text => (
         <MessageIconActions
@@ -452,9 +286,6 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
           time={data.time}
           clock="start"
           className={css.actions}
-          onEdit={editable && text !== ''
-            ? () => { edit.begin(data.seq, text, edit.latestUserSeq) }
-            : undefined}
           t={t}
         />
       )}

+ 0 - 16
packages/client/ui-chat/src/client/contract/slots.ts

@@ -84,27 +84,12 @@ export interface ChatNodeOwnerProps {
    * rendering closure.
    */
   loadImage: MessageImageLoader
-  messageEdit?: MessageEditController | undefined
   renderMessageImages: RenderMessageImages
   fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
   /** Turn-process state when this Node belongs to a projected Turn. */
   turnProcess?: TurnProcessOwnerProps | undefined
 }
 
-/** Session-local inline editor state and actions shared by user-message rows. */
-export interface MessageEditController {
-  readonly latestUserSeq: number
-  readonly current: {
-    readonly seq: number
-    readonly text: string
-    readonly expectedLastUserSeq: number
-  } | null
-  begin: (seq: number, text: string, expectedLastUserSeq: number) => void
-  change: (text: string) => void
-  submit: () => void
-  cancel: () => void
-}
-
 /** Shared presentation state for one Turn-process answer generation. */
 export interface TurnProcessOwnerProps {
   readonly spec: TurnProcessSpec
@@ -165,7 +150,6 @@ export interface ChatViewInjected {
     read: () => ChatScrollPosition | null
   }
   forkAt: (seq: number) => void
-  editMessage?: ((seq: number, expectedLastUserSeq: number, text: string) => Promise<void>) | undefined
   fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
 }
 

+ 0 - 3
packages/client/ui-chat/src/client/contract/snapshot.ts

@@ -95,8 +95,6 @@ export interface ChatSnapshot {
   readonly locations: ChatLocationNodeIndex
   readonly navigation: ChatTurnNavigationIndex
   readonly timeline: ConversationTimelineSnapshot
-  /** Loaded model-surface membership used to gate historical message actions. */
-  readonly currentSurfaceSeqs?: ReadonlySet<number>
   readonly legacy: LegacyConversationSlice
 }
 
@@ -134,7 +132,6 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
     items: () => EMPTY_LIST,
   },
   timeline: EMPTY_TIMELINE,
-  currentSurfaceSeqs: new Set(),
   legacy: {
     nodes: EMPTY_LIST,
     turnTimings: new Map(),

+ 3 - 16
packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts

@@ -1,7 +1,7 @@
 import type { Context } from '@deepseek-ai/cordis'
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
 import type {
-  ConversationLocation, ConversationPresentation, ConversationTimelineSnapshot, ConversationViewBuilder,
+  ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder,
   ConversationViewDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
@@ -20,7 +20,6 @@ const EMPTY_KEYS: readonly string[] = []
 const EMPTY_TURNS: readonly number[] = []
 const EMPTY_ITEMS: readonly TurnNavigationItem[] = []
 const EMPTY_LIST: readonly never[] = []
-const EMPTY_SURFACE_SEQS: ReadonlySet<number> = new Set()
 
 function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
   return left.length === right.length && left.every((value, index) => value === right[index])
@@ -769,7 +768,6 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
   replace(input: {
     readonly nodes: readonly ChatConversationViewNode[]
     readonly timeline: ConversationTimelineSnapshot
-    readonly presentation?: ConversationPresentation
   }): ChatSnapshot {
     const nodes = this.referenceLabels.replace(input.nodes)
     this.store.replace(nodes)
@@ -778,11 +776,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     this.store.replaceProcesses(this.order, this.locations)
     this.navigation.rebuild(input.timeline, this.locations, this.store)
     this.timeline = input.timeline
-    const snapshot = this.snapshot(
-      input.timeline,
-      this.legacy.replace(nodes, input.timeline),
-      input.presentation?.currentSurfaceSeqs ?? EMPTY_SURFACE_SEQS,
-    )
+    const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
     this.store.publish()
     return snapshot
   }
@@ -790,7 +784,6 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
   apply(input: {
     readonly upserts: readonly ChatConversationViewNode[]
     readonly timeline: ConversationTimelineSnapshot
-    readonly presentation?: ConversationPresentation
   }): ChatSnapshot {
     const upserts = this.referenceLabels.apply(input.upserts, this.store)
     const processTurns = new Set<number>()
@@ -826,11 +819,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
       this.navigation.touch(turnsOf(contentOnly), this.locations, this.store)
     }
     this.timeline = input.timeline
-    const snapshot = this.snapshot(
-      input.timeline,
-      this.legacy.apply(upserts, input.timeline),
-      input.presentation?.currentSurfaceSeqs ?? EMPTY_SURFACE_SEQS,
-    )
+    const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
     this.store.publish()
     return snapshot
   }
@@ -838,7 +827,6 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
   private snapshot(
     timeline: ConversationTimelineSnapshot,
     legacy = this.legacy.replace(EMPTY_LIST, timeline),
-    currentSurfaceSeqs: ReadonlySet<number> = new Set(),
   ): ChatSnapshot {
     return {
       order: this.order,
@@ -846,7 +834,6 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
       locations: this.locations,
       navigation: this.navigation,
       timeline,
-      currentSurfaceSeqs,
       legacy,
     }
   }

+ 1 - 2
packages/client/ui-chat/src/client/conversation-nodes/message.ts

@@ -1,6 +1,5 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { isConversationReplacementEvent } from '@deepseek-ai/dsh-session/conversation'
 import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts'
 import type { InboxState } from './inbox.ts'
@@ -41,7 +40,7 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
   kind: 'input-message',
   target: 'chat',
   match: event => event.type === 'user/message'
-    && (isAppendSurfaceEvent(event) || isConversationReplacementEvent(event))
+    && isAppendSurfaceEvent(event)
     && !isCompactionCheckpoint(event)
     ? { id: String(event.data.id), role: 'start' }
     : null,

+ 0 - 6
packages/client/ui-chat/src/client/locale.ts

@@ -75,9 +75,6 @@ export const zh = {
   'message.turnProcess.thoughtForAWhile': '已思考',
   'message.turnProcess.separator': ' · ',
   'message.stopped': '已停止',
-  'message.edit': '编辑消息',
-  'message.saveAndRegenerate': '保存并重新生成',
-  'message.cancelEdit': '取消编辑',
   'message.branch': '在新对话中分支',
   'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
   'message.retry.active': '正在重试模型请求',
@@ -195,9 +192,6 @@ export const en = {
   'message.turnProcess.thoughtForAWhile': 'Thought for a while',
   'message.turnProcess.separator': ' · ',
   'message.stopped': 'Stopped',
-  'message.edit': 'Edit message',
-  'message.saveAndRegenerate': 'Save and regenerate',
-  'message.cancelEdit': 'Cancel editing',
   'message.branch': 'Branch into a new conversation',
   'message.branchUnavailable': 'Available only on the last message of a completed turn',
   'message.retry.active': 'Retrying model request',

+ 0 - 23
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -40,10 +40,6 @@ function sessionFakeFor() {
       ok: true,
       value: { attachment: ATTACHMENT, data: Uint8Array.of(1) },
     })),
-    edit: vi.fn<ISession['edit']>(() => Promise.resolve({
-      ok: true,
-      value: { accepted: true, messageSeq: 20 },
-    })),
     prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
     cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
   } satisfies SessionBehaviorOverrides
@@ -116,25 +112,6 @@ describe('Chat inject API', () => {
     await b.runtime.dispose()
   })
 
-  it('edits through the Session Controller and surfaces business failures', async () => {
-    const b = await bench()
-    const { injected } = b.chatViewApi(ROOT)
-
-    await expect(injected.editMessage?.(10, 12, 'edited')).resolves.toBeUndefined()
-    expect(b.session.edit).toHaveBeenCalledWith(10, 12, 'edited')
-
-    b.session.edit.mockResolvedValueOnce({
-      ok: false,
-      error: new RemoteError('session/edit-stale', 'stale', {
-        sessionId: ROOT,
-        messageSeq: 10,
-      }),
-    })
-    await expect(injected.editMessage?.(10, 12, 'stale edit'))
-      .rejects.toThrow('message edit failed: session/edit-stale: stale')
-    await b.runtime.dispose()
-  })
-
   it('writes Chat selection before opening details', async () => {
     const b = await bench()
     const { instance, injected } = b.chatViewApi(ROOT)

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

@@ -61,7 +61,6 @@ function sessionSnapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnaps
     sessionId: SID,
     queue: [],
     pendingSubmissions: [],
-    pendingEdit: null,
     running: false,
     removed: false,
     openState: 'open',
@@ -260,8 +259,6 @@ function makeHarness(
     read: () => savedScroll,
   }
   const forkAt = vi.fn()
-  const editMessage = vi.fn<(seq: number, expectedLastUserSeq: number, text: string) => Promise<void>>()
-    .mockResolvedValue(undefined)
   // Rows and the harness must observe the same chat-store instance.
   const chat = createChatStore().create()
   const transcriptView = createSnapshotStore<TranscriptViewMode>('compact')
@@ -402,7 +399,6 @@ function makeHarness(
     loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
     chatScroll,
     forkAt,
-    editMessage,
     // Absent-service default; mention tests override with a real resolver.
     fileMentions: () => undefined,
     t,
@@ -430,7 +426,7 @@ function makeHarness(
     set, setSession: session.set, setChat: chatSource.set, ChatView, props,
     openDetails, openFile, loadOlder, loadThrough, openView,
     setOutline: (value: unknown) => { outlineValue = value },
-    chatScroll, forkAt, editMessage, setSelection, toolOwners,
+    chatScroll, forkAt, setSelection, toolOwners,
     setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
     setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
       nodeSlotOverride = renderer
@@ -1882,167 +1878,6 @@ describe('ChatView', () => {
     expect(view.container.querySelectorAll('[data-chat-flow-kind="user"]')).toHaveLength(2)
   })
 
-  it('edits only the latest current turn-opening user message with Composer keyboard semantics', async () => {
-    const base = chatSnapshotFixture({
-      nodes: [user(1, 'question'), assistant(2, 'answer'), user(4, 'later')],
-      turnEnds: new Map([[1, 3], [2, 5]]),
-    })
-    const h = makeHarness({}, {}, { ...base, currentSurfaceSeqs: new Set([1, 2, 4]) })
-    const view = render(<h.ChatView {...h.props} />)
-    let scrollHeight = 30
-    vi.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get').mockImplementation(() => scrollHeight)
-
-    const edits = view.getAllByRole('button', { name: '编辑消息' })
-    expect(edits).toHaveLength(1)
-    fireEvent.click(edits[0]!)
-    const editor = view.getByRole('textbox', { name: '编辑消息' })
-    expect((editor as HTMLTextAreaElement).value).toBe('later')
-    expect((editor as HTMLTextAreaElement).style.height).toBe('80px')
-
-    scrollHeight = 320
-    fireEvent.change(editor, { target: { value: 'edited question' } })
-    expect((editor as HTMLTextAreaElement).style.height).toBe('240px')
-    expect((editor as HTMLTextAreaElement).style.overflowY).toBe('auto')
-
-    scrollHeight = 44
-    fireEvent.change(editor, { target: { value: 'shorter' } })
-    expect((editor as HTMLTextAreaElement).style.height).toBe('80px')
-    expect((editor as HTMLTextAreaElement).style.overflowY).toBe('hidden')
-
-    fireEvent.change(editor, { target: { value: 'edited question' } })
-    fireEvent.keyDown(editor, { key: 'Enter', shiftKey: true })
-    expect(h.editMessage).not.toHaveBeenCalled()
-    fireEvent.keyDown(editor, { key: 'Enter' })
-
-    await waitFor(() => {
-      expect(h.editMessage).toHaveBeenCalledWith(4, 4, 'edited question')
-    })
-    expect(view.queryByRole('textbox', { name: '编辑消息' })).toBeNull()
-  })
-
-  it('withholds Edit from earlier, steering, shadowed, and subagent messages', () => {
-    const base = chatSnapshotFixture({
-      nodes: [user(1, 'current'), steering(2, 'steer', 1), user(4, 'shadowed')],
-    })
-    const h = makeHarness({}, {}, { ...base, currentSurfaceSeqs: new Set([1, 2]) })
-    const view = render(<h.ChatView {...h.props} />)
-    expect(view.queryByRole('button', { name: '编辑消息' })).toBeNull()
-
-    act(() => {
-      h.setSession({
-        subagent: {
-          address: {
-            parentSessionId: 'parent' as never,
-            childSessionId: SID,
-            mode: 'continuable',
-          },
-          parentAvailable: true,
-        },
-      })
-    })
-    expect(view.queryByRole('button', { name: '编辑消息' })).toBeNull()
-  })
-
-  it('cancels with Escape and submits with the inline action', async () => {
-    const base = chatSnapshotFixture({ nodes: [user(1, 'question')] })
-    const h = makeHarness({}, {}, { ...base, currentSurfaceSeqs: new Set([1]) })
-    const view = render(<h.ChatView {...h.props} />)
-
-    fireEvent.click(view.getByRole('button', { name: '编辑消息' }))
-    fireEvent.keyDown(view.getByRole('textbox', { name: '编辑消息' }), { key: 'Escape' })
-    expect(view.queryByRole('textbox', { name: '编辑消息' })).toBeNull()
-    expect(h.editMessage).not.toHaveBeenCalled()
-
-    fireEvent.click(view.getByRole('button', { name: '编辑消息' }))
-    fireEvent.change(view.getByRole('textbox', { name: '编辑消息' }), { target: { value: 'saved' } })
-    fireEvent.click(view.getByRole('button', { name: '保存并重新生成' }))
-    await waitFor(() => {
-      expect(h.editMessage).toHaveBeenCalledWith(1, 1, 'saved')
-    })
-  })
-
-  it('reports an unexpected edit callback failure after closing the editor', async () => {
-    const base = chatSnapshotFixture({ nodes: [user(1, 'question')] })
-    const h = makeHarness({}, {}, { ...base, currentSurfaceSeqs: new Set([1]) })
-    const failure = new Error('client edit failed')
-    h.editMessage.mockRejectedValue(failure)
-    const report = vi.spyOn(console, 'error').mockImplementation(() => {})
-    const view = render(<h.ChatView {...h.props} />)
-
-    fireEvent.click(view.getByRole('button', { name: '编辑消息' }))
-    fireEvent.click(view.getByRole('button', { name: '保存并重新生成' }))
-
-    await waitFor(() => {
-      expect(report).toHaveBeenCalledWith('[ui-chat] message edit failed:', failure)
-    })
-    expect(view.queryByRole('textbox', { name: '编辑消息' })).toBeNull()
-    report.mockRestore()
-  })
-
-  it('cancels inline edit when a Composer submission begins', () => {
-    const base = chatSnapshotFixture({ nodes: [user(1, 'question')] })
-    const h = makeHarness({}, {}, { ...base, currentSurfaceSeqs: new Set([1]) })
-    const view = render(<h.ChatView {...h.props} />)
-    fireEvent.click(view.getByRole('button', { name: '编辑消息' }))
-    expect(view.getByRole('textbox', { name: '编辑消息' })).toBeTruthy()
-
-    act(() => {
-      h.setSession({
-        pendingSubmissions: [{
-          requestId: 'composer-send' as never,
-          placement: 'queued',
-          time: 1,
-          text: 'new message',
-          images: [],
-        }],
-      })
-    })
-
-    expect(view.queryByRole('textbox', { name: '编辑消息' })).toBeNull()
-  })
-
-  it('keeps inline edit open when a pre-existing submission retires', () => {
-    const base = chatSnapshotFixture({ nodes: [user(1, 'question')] })
-    const h = makeHarness({}, {
-      pendingSubmissions: [{
-        requestId: 'older-send' as never,
-        placement: 'queued',
-        time: 1,
-        text: 'already pending',
-        images: [],
-      }],
-    }, { ...base, currentSurfaceSeqs: new Set([1]) })
-    const view = render(<h.ChatView {...h.props} />)
-    fireEvent.click(view.getByRole('button', { name: '编辑消息' }))
-
-    act(() => { h.setSession({ pendingSubmissions: [] }) })
-
-    expect(view.getByRole('textbox', { name: '编辑消息' })).toBeTruthy()
-  })
-
-  it('optimistically replaces the selected message and every later row', () => {
-    const base = chatSnapshotFixture({
-      nodes: [user(1, 'question'), assistant(2, 'old answer'), user(4, 'later')],
-      turnEnds: new Map([[1, 3], [2, 5]]),
-    })
-    const h = makeHarness({}, {
-      pendingEdit: {
-        requestId: 'edit-request' as never,
-        targetSeq: 1,
-        expectedLastUserSeq: 4,
-        text: 'edited question',
-        time: 9_000,
-      },
-    }, { ...base, currentSurfaceSeqs: new Set([1, 2, 4]) })
-    const view = render(<h.ChatView {...h.props} />)
-
-    expect(view.getByText('edited question', { exact: true })).toBeTruthy()
-    expect(view.queryByText('question', { exact: true })).toBeNull()
-    expect(view.queryByText('old answer', { exact: true })).toBeNull()
-    expect(view.queryByText('later', { exact: true })).toBeNull()
-    expect(view.queryByRole('button', { name: '编辑消息' })).toBeNull()
-  })
-
   it('the run-time label is withheld when the turn start is outside the window', () => {
     const h = makeHarness({
       nodes: [assistant(16, 'tail without trigger')],

+ 0 - 1
packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx

@@ -57,7 +57,6 @@ function sessionSnapshot(): SessionSnapshot {
     sessionId: SID,
     queue: [],
     pendingSubmissions: [],
-    pendingEdit: null,
     running: false,
     removed: false,
     openState: 'open',

+ 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: 5cdabefe1f813152ab7713c7f5de2b9ca28b0339
-README.zh.md: 8edfdcda59299493d5127d6e06002933f80dd4fa
+README.md: 3292c39477a051ac406f453e781376164333ee78
+README.zh.md: 433b2e6c8b3580dadf5dd9e38eaea7b1ab7022a6

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

@@ -27,9 +27,7 @@ English | [中文](README.zh.md)
 
 `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
 
-The adapter folds explicit `conversationOp` replacements before handing entries to the assembler. Events in replaced raw ranges remain in the Session log but are absent from every current Conversation target; ordinary surface replacements such as compaction remain visible according to each target's existing rules. The same presentation state supplies loaded current-surface membership to target builders, so actions can distinguish editable model-context messages without running another fold.
-
-Each retained `SessionEventLikeEntry` reaches the assembler unchanged. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
+The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
 
 A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
 

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

@@ -27,9 +27,7 @@ kind: "package-reference"
 
 `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。
 
-adapter 会先折叠显式 `conversationOp` 替换,再把 entry 交给 assembler。被替换原始区间内的事件仍保留在 Session 日志中,但不会进入任何当前 Conversation target;压缩等普通 surface 替换仍按各 target 的既有规则展示。同一份展示状态还会向 target builder 提供已加载的当前 surface 成员关系,使动作可以辨别哪些模型上下文消息可编辑,而无需再运行一次折叠。
-
-每个被保留的 `SessionEventLikeEntry` 都会原样到达 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 则只接收标准 event,assembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
+adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 则只接收标准 event,assembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
 
 shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
 

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

@@ -250,12 +250,6 @@ export interface ConversationTimelineSnapshot {
   readonly turns: ReadonlyMap<number, TurnLocation>
 }
 
-/** Current model-surface membership accompanying one Conversation publication. */
-export interface ConversationPresentation {
-  /** Loaded surface-event seqs that remain in the current model context. */
-  readonly currentSurfaceSeqs: ReadonlySet<number>
-}
-
 /** Per-Session incremental builder for one view target. */
 export interface ConversationViewBuilder<Node extends ConversationViewNode = ConversationViewNode, Snapshot = unknown> {
   readonly empty: Snapshot
@@ -267,7 +261,6 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
   replace(input: {
     readonly nodes: readonly Node[]
     readonly timeline: ConversationTimelineSnapshot
-    readonly presentation?: ConversationPresentation
   }): Snapshot
   /**
    * Apply only Nodes whose materialized values changed in this transaction.
@@ -277,7 +270,6 @@ export interface ConversationViewBuilder<Node extends ConversationViewNode = Con
   apply(input: {
     readonly upserts: readonly Node[]
     readonly timeline: ConversationTimelineSnapshot
-    readonly presentation?: ConversationPresentation
   }): Snapshot
 }
 

+ 5 - 27
packages/client/ui-conversation/src/client/conversation/assembler.ts

@@ -4,7 +4,7 @@ import type {
 import type {
   ConversationContextReader, ConversationLocationData, ConversationMatch,
   ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
-  ConversationLocationDataScope, ConversationPresentation, ConversationPublication, ConversationViewBuilder,
+  ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder,
   ConversationStartMatch,
   ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap,
   ConversationViewSnapshotStore,
@@ -57,7 +57,6 @@ const PUBLICATION_RANK: Record<ConversationPublication, number> = {
 }
 
 const LOCATION_DATA_SCOPES: readonly ConversationLocationDataScope[] = ['step', 'turn']
-const EMPTY_PRESENTATION: ConversationPresentation = { currentSurfaceSeqs: new Set() }
 
 function emptyLocationData(): Record<ConversationLocationDataScope, ConversationLocationData | null> {
   return { step: null, turn: null }
@@ -169,7 +168,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
   private readonly dependents = new Map<string, Set<InternalContext>>()
   private readonly views = new Map<string, ViewState>()
   private readonly activeTargets = new Set<string>()
-  private presentation: ConversationPresentation = EMPTY_PRESENTATION
   private hasMore = false
   private replacePending = true
   private timelineDirty = true
@@ -189,14 +187,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
    * Replace the complete loaded window after open, resync, or gap repair.
    * @param entries - complete contiguous window.
    * @param hasMore - whether older history remains outside the window.
-   * @param presentation - current loaded conversation and model-surface membership.
    * @returns immediate publication request.
    */
-  replaceWindow(
-    entries: readonly SessionEventLikeEntry[],
-    hasMore: boolean,
-    presentation: ConversationPresentation = EMPTY_PRESENTATION,
-  ): ConversationPublication {
+  replaceWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): ConversationPublication {
     this.contexts.clear()
     this.contextsByKind.clear()
     this.contextsBySeq.clear()
@@ -207,7 +200,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
     this.revised.clear()
     this.dependents.clear()
     this.hasMore = hasMore
-    this.presentation = presentation
     const sorted = [...entries].sort((left, right) => left.event.seq - right.event.seq)
     for (const entry of sorted) this.inputs.set(entry.event.seq, entry)
     this.locationIndex.rebuild(sorted)
@@ -223,17 +215,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
   /**
    * Add one contiguous live tail event without scanning existing Contexts.
    * @param record - appended Session event entry.
-   * @param presentation - current loaded conversation and model-surface membership.
    * @returns highest requested publication cadence.
    */
-  append(
-    record: SessionLiveEventEntry,
-    presentation: ConversationPresentation = this.presentation,
-  ): ConversationPublication {
+  append(record: SessionLiveEventEntry): ConversationPublication {
     const event = record.event
     if (this.inputs.has(event.seq)) return 'none'
     this.revised.clear()
-    this.presentation = presentation
     this.inputs.set(event.seq, record)
     let publication: ConversationPublication = 'none'
     if (isLocationBoundary(event.type)) {
@@ -258,18 +245,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
    * Add an older page while preserving existing Context and view identities.
    * @param entries - newly loaded older Events.
    * @param hasMore - whether history still precedes the expanded window.
-   * @param presentation - current loaded conversation and model-surface membership.
    * @returns highest requested publication cadence.
    */
-  prepend(
-    entries: readonly SessionEventLikeEntry[],
-    hasMore: boolean,
-    presentation: ConversationPresentation = this.presentation,
-  ): ConversationPublication {
+  prepend(entries: readonly SessionEventLikeEntry[], hasMore: boolean): ConversationPublication {
     this.revised.clear()
     let publication: ConversationPublication = 'none'
     const previousHasMore = this.hasMore
-    this.presentation = presentation
     const fresh = entries
       .filter(entry => !this.inputs.has(entry.event.seq))
       .sort((left, right) => left.event.seq - right.event.seq)
@@ -299,7 +280,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
    */
   rebuildRegistry(): ConversationPublication {
     this.resetViewBuilders()
-    return this.replaceWindow(this.sortedInputs(), this.hasMore, this.presentation)
+    return this.replaceWindow(this.sortedInputs(), this.hasMore)
   }
 
   /**
@@ -319,7 +300,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
         view.snapshot = builder.replace({
           nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)),
           timeline: this.locationIndex.snapshot(),
-          presentation: this.presentation,
         })
         published = true
       }
@@ -344,7 +324,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
       view.snapshot = builder.apply({
         upserts,
         timeline: this.locationIndex.snapshot(),
-        presentation: this.presentation,
       })
       published = true
     }
@@ -811,7 +790,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
     view.snapshot = builder.replace({
       nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)),
       timeline: this.locationIndex.snapshot(),
-      presentation: this.presentation,
     })
   }
 

+ 3 - 24
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -16,7 +16,6 @@ import type { ConversationSnapshot } from '../contract/snapshot.ts'
 import type { ConversationPromptSnapshot, RequestPromptInspection } from '../contract/request-inspection.ts'
 import { inspectRequestPrompt } from '../contract/request-inspection.ts'
 import { ConversationNodeAssembler } from './assembler.ts'
-import { ConversationPresentationState } from './presentation.ts'
 import { ConversationEventRegistry } from './event-registry.ts'
 import { HistoricalImageCache } from './historical-images.ts'
 import { ConversationViewRegistry } from './view-registry.ts'
@@ -48,7 +47,6 @@ class BoundConversation implements ConversationBinding {
   private revision = -1
   private frame: number | undefined
   private disposeFeed: () => void = () => {}
-  private readonly presentation = new ConversationPresentationState()
 
   constructor(
     feed: SessionEventSource,
@@ -94,12 +92,7 @@ class BoundConversation implements ConversationBinding {
 
   private replace(window: SessionEventWindow): void {
     this.revision = window.revision
-    this.presentation.replace(window.entries)
-    this.publish(this.assembler.replaceWindow(
-      window.entries.filter(entry => this.presentation.visible(entry)),
-      window.hasMore,
-      this.presentation.snapshot(),
-    ))
+    this.publish(this.assembler.replaceWindow(window.entries, window.hasMore))
   }
 
   private accept(window: SessionEventWindow): void {
@@ -111,26 +104,12 @@ class BoundConversation implements ConversationBinding {
     this.revision = window.revision
     switch (window.change.kind) {
       case 'prepend':
-        this.presentation.apply(window.change.entries)
-        this.publish(this.assembler.prepend(
-          window.change.entries.filter(entry => this.presentation.visible(entry)),
-          window.hasMore,
-          this.presentation.snapshot(),
-        ))
+        this.publish(this.assembler.prepend(window.change.entries, window.hasMore))
         return
       case 'append': {
-        const replacesLoadedState = window.change.entries.some(entry =>
-          entry.event.type === 'user/message'
-          && (entry.event.surfaceOp !== 'append' || entry.event.conversationOp !== undefined))
-        if (replacesLoadedState) {
-          this.replace(window)
-          return
-        }
-        this.presentation.apply(window.change.entries)
         let publication: ConversationPublication = 'none'
         for (const event of window.change.entries) {
-          if (!this.presentation.visible(event)) continue
-          const next = this.assembler.append(event, this.presentation.snapshot())
+          const next = this.assembler.append(event)
           if (next === 'immediate' || publication === 'none') publication = next
         }
         this.publish(publication)

+ 0 - 85
packages/client/ui-conversation/src/client/conversation/presentation.ts

@@ -1,85 +0,0 @@
-/** Loaded-window model-surface and user-facing conversation visibility. */
-
-import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
-import {
-  isConversationReplacementEvent,
-  isConversationSeqVisible,
-  type ConversationHiddenRange,
-} from '@deepseek-ai/dsh-session/conversation'
-import { isReplacementSurfaceEvent, isSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
-import type { ConversationPresentation } from '../contract/conversation.ts'
-
-/** Mutable presentation state owned by one Session Conversation binding. */
-export class ConversationPresentationState {
-  private readonly currentSurfaceSeqs = new Set<number>()
-  private readonly shadowedSurfaceSeqs = new Set<number>()
-  private hiddenRanges: ConversationHiddenRange[] = []
-
-  /**
-   * Replace all loaded presentation facts from one contiguous event window.
-   * @param entries - complete loaded event window.
-   */
-  replace(entries: readonly SessionEventLikeEntry[]): void {
-    this.currentSurfaceSeqs.clear()
-    this.shadowedSurfaceSeqs.clear()
-    this.hiddenRanges = []
-    this.apply(entries)
-  }
-
-  /**
-   * Apply newly appended or prepended standard events to presentation state.
-   * @param entries - newly loaded event entries.
-   */
-  apply(entries: readonly SessionEventLikeEntry[]): void {
-    const events = entries
-      .filter((entry): entry is Extract<SessionEventLikeEntry, { type: 'event' }> => entry.type === 'event')
-      .map(entry => entry.event)
-      .sort((left, right) => left.seq - right.seq)
-    for (const event of events) {
-      if (isReplacementSurfaceEvent(event)) {
-        for (const seq of event.sourceEventSeqs ?? []) {
-          this.shadowedSurfaceSeqs.add(seq)
-          this.currentSurfaceSeqs.delete(seq)
-        }
-      }
-      if (isSurfaceEvent(event) && !this.shadowedSurfaceSeqs.has(event.seq)) {
-        this.currentSurfaceSeqs.add(event.seq)
-      }
-      if (isConversationReplacementEvent(event)) {
-        this.addHiddenRange(event.conversationOp.start, event.conversationOp.end)
-      }
-    }
-  }
-
-  /**
-   * Test whether one loaded entry belongs to the current user-facing generation.
-   * @param entry - loaded event or packed chunk row.
-   * @returns whether current Conversation targets may consume the entry.
-   */
-  visible(entry: SessionEventLikeEntry): boolean {
-    return isConversationSeqVisible(entry.event.seq, this.hiddenRanges)
-  }
-
-  /**
-   * Read the presentation data supplied to target builders.
-   * @returns detached current model-surface membership.
-   */
-  snapshot(): ConversationPresentation {
-    return { currentSurfaceSeqs: new Set(this.currentSurfaceSeqs) }
-  }
-
-  private addHiddenRange(start: ConversationHiddenRange['start'], end: ConversationHiddenRange['end']): void {
-    const ranges = [...this.hiddenRanges, { start, end }]
-      .sort((left, right) => left.start - right.start || left.end - right.end)
-    const merged: ConversationHiddenRange[] = []
-    for (const range of ranges) {
-      const previous = merged.at(-1)
-      if (previous === undefined || range.start > previous.end + 1) {
-        merged.push({ ...range })
-      } else if (range.end > previous.end) {
-        merged[merged.length - 1] = { start: previous.start, end: range.end }
-      }
-    }
-    this.hiddenRanges = merged
-  }
-}

+ 1 - 1
packages/client/ui-conversation/src/client/index.ts

@@ -7,7 +7,7 @@ export type { IConversation } from './service.ts'
 export type {
   ConversationContextReader, ConversationLocation,
   ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataSource,
-  ConversationLocationDataStore, ConversationPresentation,
+  ConversationLocationDataStore,
   ConversationMatch, ConversationMatchResult, ConversationNodeContext,
   ConversationNodeDefinition, ConversationPreviousContext, ConversationPublication,
   ConversationStartMatch,

+ 0 - 65
packages/client/ui-conversation/tests/conversation-presentation.client.spec.ts

@@ -1,65 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
-import { ConversationPresentationState } from '../src/client/conversation/presentation.ts'
-
-function entry(event: SessionEvent): SessionEventLikeEntry {
-  return { type: 'event', event }
-}
-
-function user(seq: number, text: string, surfaceOp: SessionEvent<'user/message'>['surfaceOp'] = 'append'): SessionEvent<'user/message'> {
-  return {
-    type: 'user/message',
-    seq: SessionSeq(seq),
-    time: seq,
-    data: createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }),
-    surfaceOp,
-  }
-}
-
-describe('ConversationPresentationState', () => {
-  it('hides an edited raw range while exposing the replacement on the current surface', () => {
-    const original = user(1, 'original')
-    const answer = {
-      type: 'assistant/message', seq: SessionSeq(2), time: 2,
-      data: { turn: 1, step: 1, message: { id: 'a' as never, role: 'assistant' as const, content: [{ type: 'text' as const, text: 'old' }], source: { kind: 'model' as const, provider: 'p', model: 'm' } } },
-      surfaceOp: 'append' as const,
-      sourceEventSeqs: [],
-    } satisfies SessionEvent<'assistant/message'>
-    const replacement = {
-      ...user(5, 'edited', { op: 'replace', start: original.seq, end: answer.seq }),
-      sourceEventSeqs: [original.seq, answer.seq],
-      conversationOp: { op: 'replace' as const, start: SessionSeq(0), end: SessionSeq(3) },
-    }
-    const state = new ConversationPresentationState()
-    const entries = [
-      entry({ type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } }),
-      entry(original),
-      entry(answer),
-      entry({ type: 'turn/end', seq: SessionSeq(3), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
-      entry({ type: 'turn/start', seq: SessionSeq(4), time: 4, data: { turn: 2 } }),
-      entry(replacement),
-    ]
-
-    state.replace(entries)
-
-    expect(entries.map(value => state.visible(value))).toEqual([false, false, false, false, true, true])
-    expect([...state.snapshot().currentSurfaceSeqs]).toEqual([replacement.seq])
-  })
-
-  it('keeps compacted transcript events visible while removing them from current surface membership', () => {
-    const original = user(0, 'original')
-    const checkpoint = {
-      ...user(1, 'summary', { op: 'replace', start: original.seq, end: original.seq }),
-      sourceEventSeqs: [original.seq],
-    }
-    const state = new ConversationPresentationState()
-    const entries = [entry(original), entry(checkpoint)]
-
-    state.replace(entries)
-
-    expect(entries.map(value => state.visible(value))).toEqual([true, true])
-    expect([...state.snapshot().currentSurfaceSeqs]).toEqual([checkpoint.seq])
-  })
-})

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

@@ -25,7 +25,6 @@ function sessionSnapshot(): SessionSnapshot {
     sessionId: SESSION_ID,
     queue: [],
     pendingSubmissions: [],
-    pendingEdit: null,
     running: false,
     subagent: null,
     removed: false,
@@ -50,7 +49,6 @@ function fakeSession(): SessionFace {
     subscribe: listener => snapshot.subscribe(listener),
     beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }),
     prompt: () => Promise.reject(new Error('unused fake Session operation')),
-    edit: () => Promise.reject(new Error('unused fake Session operation')),
     readAttachment: () => Promise.reject(new Error('unused fake Session operation')),
     updateQueue: () => Promise.reject(new Error('unused fake Session operation')),
     cancel: () => Promise.reject(new Error('unused fake Session operation')),

+ 0 - 1
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -40,7 +40,6 @@ function snapshotWith(queue: QueuedMessage[]): SessionSnapshot {
     sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null,
     hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null,
     pendingSubmissions: [],
-    pendingEdit: null,
     lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false,
   }
 }

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно