ソースを参照

fix(session): address v2 migration review

Tianyi Cui 1 ヶ月 前
親
コミット
67c8a58854
74 ファイル変更、917 行追加、391 行削除
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml
  5. 7 3
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
  6. 7 3
      .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md
  7. 2 2
      docs/config-catalog.i18n.yaml
  8. 1 1
      docs/config-catalog.md
  9. 1 1
      docs/config-catalog.zh.md
  10. 2 2
      docs/deepseek-llm-api-wire-extensions.i18n.yaml
  11. 3 3
      docs/deepseek-llm-api-wire-extensions.md
  12. 3 3
      docs/deepseek-llm-api-wire-extensions.zh.md
  13. 2 2
      docs/subsystems/core.i18n.yaml
  14. 10 6
      docs/subsystems/core.md
  15. 10 6
      docs/subsystems/core.zh.md
  16. 2 2
      packages/api/session-controller/README.i18n.yaml
  17. 0 0
      packages/api/session-controller/README.md
  18. 0 0
      packages/api/session-controller/README.zh.md
  19. 22 4
      packages/api/session-controller/src/client/contract/events.ts
  20. 3 0
      packages/api/session-controller/src/client/index.ts
  21. 26 13
      packages/api/session-controller/src/client/sessions/assistant-stream.ts
  22. 5 0
      packages/api/session-controller/src/client/sessions/session.ts
  23. 10 9
      packages/api/session-controller/tests/assistant-stream.client.spec.ts
  24. 62 1
      packages/api/session-controller/tests/client-contract.client.spec.ts
  25. 1 1
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  26. 1 0
      packages/client/connection/package.json
  27. 15 6
      packages/client/connection/src/client/fixture.ts
  28. 25 33
      packages/client/connection/tests/fixture.client.spec.ts
  29. 1 1
      packages/client/tsdown.client.ts
  30. 101 11
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  31. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  32. 1 1
      packages/client/ui-conversation/README.md
  33. 1 1
      packages/client/ui-conversation/README.zh.md
  34. 45 1
      packages/client/ui-conversation/src/client/conversation/assembler.ts
  35. 7 0
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  36. 33 1
      packages/client/ui-conversation/src/client/conversation/location-index.ts
  37. 68 0
      packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts
  38. 20 8
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  39. 76 4
      packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
  40. 5 1
      packages/extensions/cordis-client-runner/src/client/api-catalog.ts
  41. 15 8
      packages/llm/llm/src/assistant-stream.ts
  42. 23 6
      packages/llm/llm/tests/assistant-stream.spec.ts
  43. 7 5
      packages/session/session-format-catalog/tests/catalog.spec.ts
  44. 1 0
      packages/session/session-format-v0-to-v1/src/dispositions.ts
  45. 17 4
      packages/session/session-format-v0-to-v1/src/relationships.ts
  46. 2 2
      packages/session/session-format-v1-to-v2/README.i18n.yaml
  47. 5 5
      packages/session/session-format-v1-to-v2/README.md
  48. 5 5
      packages/session/session-format-v1-to-v2/README.zh.md
  49. 3 6
      packages/session/session-format-v1-to-v2/src/codec.ts
  50. 2 0
      packages/session/session-format-v1-to-v2/src/migration.ts
  51. 25 11
      packages/session/session-format-v1-to-v2/src/validation.ts
  52. 6 4
      packages/session/session-format-v1-to-v2/tests/codec.spec.ts
  53. 3 0
      packages/session/session-format-v1-to-v2/tests/validation.spec.ts
  54. 2 2
      packages/session/session-log-deepseek/README.i18n.yaml
  55. 1 1
      packages/session/session-log-deepseek/README.md
  56. 1 1
      packages/session/session-log-deepseek/README.zh.md
  57. 1 1
      packages/session/session-log-deepseek/src/index.ts
  58. 1 1
      packages/session/session-log-deepseek/src/types.ts
  59. 1 0
      packages/session/session-log-deepseek/tests/upload.spec.ts
  60. 1 0
      packages/session/session-persistence-jsonl/package.json
  61. 79 0
      packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
  62. 5 60
      packages/test-support/session-snapshot/src/normalize.ts
  63. 1 84
      packages/test-support/session-snapshot/tests/normalize.spec.ts
  64. 6 0
      pnpm-lock.yaml
  65. 32 1
      python/sdk/tests/test_smoke_model.py
  66. 5 5
      scripts/benchmark-session-format-v1-to-v2.ts
  67. 0 1
      scripts/client-bundle-purity.spec.ts
  68. 19 11
      scripts/smoke-python-runtime.py
  69. 0 1
      snapshots/sdk/subagent-continuable-inheritance/session.1.jsonl
  70. 3 4
      snapshots/sdk/subagent-continuable/session.1.jsonl
  71. 3 3
      snapshots/sdk/subagent-dsh-sdk-diagnostic/session.jsonl
  72. 2 3
      snapshots/sdk/subagent-list-agents/session.1.jsonl
  73. 4 5
      snapshots/sdk/subagent-send-message/session.1.jsonl
  74. 50 13
      snapshots/session/headless.snapshot.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: 19650ca6a02139d6ad138c6914185f210386ce49
-2026-08-10-session-log-version-mechanism.zh.md: a54a1f30d2d30e55b7c03d529e1ca337346ce2d2
+2026-08-10-session-log-version-mechanism.md: 98eb220e49457d3a2783edefce13d053c9b94025
+2026-08-10-session-log-version-mechanism.zh.md: 8853e6ed9a1a1ed43193cfe0949fffdbb9b4a26f

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md

@@ -20,7 +20,7 @@ Session logs must be upgradable after release, and the runtime that ships first
 
 ## Consequences
 
-What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. V1 adds the static adjacent catalog, the identity v0-to-v1 edge, header-only descriptors, exact-generation JSONL publication, and current-only restoration described in [Released Session formats](2026-08-31-released-session-format-migrations.md). First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; equal-version retention lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md), and the stricter historical rule lives in the [alpha migration refusal decision](2026-08-31-alpha-historical-unknown-event-refusal.md). The unknown-type guard remains read-side because append-time vocabulary refusal would stall a live session's durability. JSONL classifies foreign versions from the minimal raw header before current-header or event parsing, so a structurally different future format reports the upgrade direction instead of "corrupt".
+What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. V1 adds the static adjacent catalog, the identity v0-to-v1 edge, header-only descriptors, exact-generation JSONL publication, and current-only restoration described in [Released Session formats](2026-08-31-released-session-format-migrations.md). V2 keeps the physical codec neutral to ordinary event vocabulary and payload additions: the adjacent edge freezes its released source and target inventories, while equal-version restoration applies the installed known-event set and current payload semantics. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; equal-version retention lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md), and the stricter historical rule lives in the [alpha migration refusal decision](2026-08-31-alpha-historical-unknown-event-refusal.md). The unknown-type guard remains read-side because append-time vocabulary refusal would stall a live session's durability. JSONL classifies foreign versions from the minimal raw header before current-header or event parsing, so a structurally different future format reports the upgrade direction instead of "corrupt".
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md

@@ -20,7 +20,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
 
 ## 影响
 
-v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。V1 添加静态相邻 catalog、恒等 v0-to-v1 迁移边、仅 header descriptor、精确代际 JSONL 发布与[已发布 Session 格式](2026-08-31-released-session-format-migrations.zh.md)定义的当前专用恢复。第一方 writer 不通过 `Session.append` 设置 `ignorable`,而一个仓库外插件仍依赖该字段;同版本保留由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义,更严格的历史规则由 [alpha 迁移拒绝决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义。未知类型守卫仍只在读取侧生效,因为 append 时的词汇拒绝会中断活跃 Session 的持久化。JSONL 会在当前 header 或事件解析前从最小原始 header 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。
+v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。V1 添加静态相邻 catalog、恒等 v0-to-v1 迁移边、仅 header descriptor、精确代际 JSONL 发布与[已发布 Session 格式](2026-08-31-released-session-format-migrations.zh.md)定义的当前专用恢复。V2 让物理 codec 对普通事件词汇与 payload 新增项保持中立:相邻迁移边冻结 released source 与 target 清单,同版本恢复则应用已安装的 known-event set 与当前 payload 语义。第一方 writer 不通过 `Session.append` 设置 `ignorable`,而一个仓库外插件仍依赖该字段;同版本保留由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义,更严格的历史规则由 [alpha 迁移拒绝决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义。未知类型守卫仍只在读取侧生效,因为 append 时的词汇拒绝会中断活跃 Session 的持久化。JSONL 会在当前 header 或事件解析前从最小原始 header 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
-2026-09-01-v2-embedded-assistant-streams.md: 80e06b34fbc675c6b6a680c1615bdebe8019bf1a
-2026-09-01-v2-embedded-assistant-streams.zh.md: 248444c62ca3004c1fbe37897b2da9377211338e
+2026-09-01-v2-embedded-assistant-streams.md: e50af4848523c92daaac1bd8b1120c26cdf62bdc
+2026-09-01-v2-embedded-assistant-streams.zh.md: 34924a43150c34571a6c5f2e7837d1b662d2ec46

+ 7 - 3
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md

@@ -27,7 +27,7 @@ The current v2 validator requires the embedded stream to reproduce a non-empty `
 
 `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop appends the complete `assistant/message` or `assistant/attempt` before a committed end frame names its type and sequence. An abandoned end has no settlement.
 
-The Web follow adapter opts into these process-local frames and adds the last durable sequence observed at each start. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages only a later matching settlement until the committed end, replaces that attempt's transient rows with the settlement, and reopens follow on a revision gap. A reconnect baseline carries the active attempt's durable start cursor and compact prefix. Paged history, replay, telemetry, token accounting, and cold UI assembly read the durable embedded stream rather than the live frames.
+The Web follow adapter opts into these process-local frames and adds the last durable sequence observed at each start. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages only a later matching settlement until the committed end, and reopens follow on a revision gap. A committed end publishes a named settlement delta that removes the attempt's transient matches, adds the durable entry, and replays only affected Conversation Contexts; an abandoned end publishes the same delta without an entry. A reconnect baseline carries the active attempt's durable start cursor and compact prefix. Paged history, replay, telemetry, token accounting, and cold UI assembly read the durable embedded stream rather than the live frames.
 
 ### Released v1 to v2 migration
 
@@ -35,7 +35,11 @@ The adjacent migration validates the complete frozen v1 artifact, groups chunks
 
 The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
 
-The v2 physical header requires `isSeeded` and stores no numeric cut. A seeded artifact marks its exact cut with `session/end-seed { inherited: true }`; decoding derives the cut from the last tagged marker. The v2 codec writes one durable event per physical row and range-encodes only `sourceEventSeqs`. Frozen v0 and v1 codecs retain packed-row decoding for their immutable historical generations.
+The v2 physical header requires `isSeeded` and stores no numeric cut. A seeded artifact marks its exact cut with `session/end-seed { inherited: true }`; decoding derives the cut from the last tagged marker. The v2 codec writes one durable event per physical row, range-encodes only `sourceEventSeqs`, and validates physical envelopes without freezing ordinary event vocabulary or payload additions. The v1-to-v2 target validator separately freezes the released-v2 inventory, while current restoration uses the installed Session vocabulary. Frozen v0 and v1 codecs retain packed-row decoding for their immutable historical generations.
+
+A fresh subagent child's constructor seed is exactly the inherited parent prefix. `Session` appends the tagged cut marker, then subagent setup appends the child-owned descriptor and delegated policies. The former descriptor-seed helper is removed, so a descriptor is never counted as inherited and cold resume replays the persisted child-owned setup. Historical snapshot fixtures that placed an untagged marker after the descriptor are corrected at their source; current comparison keeps marker count and sequence references visible.
+
+The `dsh_session_log` request extension increments its outer schema to version 2 because its Session header projection replaces `seedLength` with required `isSeeded`. Its `sessionFormatVersion` member continues to identify the embedded logical Session generation.
 
 Generation selection and publication follow the [released Session migration decision](2026-08-31-released-session-format-migrations.md): the source path, bytes, and inode remain unchanged, only the final version-named successor is published, and retained predecessors provide neither fallback nor downgrade support.
 
@@ -43,7 +47,7 @@ Generation selection and publication follow the [released Session migration deci
 
 The compact-stream tests pin exact accumulation and expansion for text, reasoning, tool arguments, raw chunks, timestamp gaps, malformed records, and detached snapshots. The v1-to-v2 tests cover successful and failed attempts, interleaving, dense sequence and reference remapping, seed-cut insertion and split refusal, strict source and target validation, one-row v2 encoding, provenance ranges, raw and Zstandard publication, and no-write current reads.
 
-The manual performance acceptance compares current v2 catalog dispatch with a direct-current read of the same physical input across three runs, 100 warmup pairs, and 600 measured pairs. It requires every pooled median and p95 regression to remain within 5%; the accepted run's worst p95 regression was 3.150%. `--smoke` reports a non-gating diagnostic sample.
+The manual performance acceptance measures static catalog-routing overhead against direct released-v2 restoration of the same already parsed physical rows across three runs, 100 warmup pairs, and 600 measured pairs. It does not compare v1 with v2 or time backend I/O. Every pooled median and p95 regression must remain within 5%; the accepted run's worst p95 regression was 3.150%. `--smoke` reports a non-gating diagnostic sample.
 
 Agent-loop tests pin durable-before-end ordering, interrupted visible prefixes, failed and retry attempts, abandonment, usage, and replay metadata. Session Controller and Conversation tests pin live transient display, reconnect baselines, committed settlement release, history replay, Chat and Trajectory parity, while TypeScript and Python SDK snapshots pin the external event representation.
 

+ 7 - 3
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md

@@ -27,7 +27,7 @@ Session format v2 没有顶层 `assistant/chunk` 事件。每个模型 attempt 
 
 `agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 命名其类型和序号前追加完整的 `assistant/message` 或 `assistant/attempt`。abandoned end 没有 settlement。
 
-Web follow adapter 显式选择接收这些进程本地 frame,并为每个 start 补充当时观察到的最后一个持久序号。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,只暂存 start 之后匹配的 settlement,在 committed end 到达时用 settlement 替换该 attempt 的瞬态 row,并在 revision 缺口时重新打开 follow。重连 baseline 携带活跃 attempt 的持久起始 cursor 与紧凑前缀。分页历史、replay、遥测、token 记账与冷 UI 组装读取持久嵌入式 stream,而不是 live frame。
+Web follow adapter 显式选择接收这些进程本地 frame,并为每个 start 补充当时观察到的最后一个持久序号。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,只暂存 start 之后匹配的 settlement,并在 revision 缺口时重新打开 follow。committed end 会发布具名 settlement delta,删除该 attempt 的 transient match、加入持久 entry,并只重放受影响的 Conversation Context;abandoned end 会发布不含 entry 的同类 delta。重连 baseline 携带活跃 attempt 的持久起始 cursor 与紧凑前缀。分页历史、replay、遥测、token 记账与冷 UI 组装读取持久嵌入式 stream,而不是 live frame。
 
 ### 已发布 v1 到 v2 迁移
 
@@ -35,7 +35,11 @@ Web follow adapter 显式选择接收这些进程本地 frame,并为每个 sta
 
 该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
 
-v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产物用 `session/end-seed { inherited: true }` 标记其精确切点;解码从最后一个 tagged marker 推导切点。v2 编解码器为每个持久事件写一条物理行,并且只对 `sourceEventSeqs` 做范围编码。冻结的 v0 与 v1 编解码器继续为不可变历史 generation 解码 packed row。
+v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产物用 `session/end-seed { inherited: true }` 标记其精确切点;解码从最后一个 tagged marker 推导切点。v2 编解码器为每个持久事件写一条物理行,只对 `sourceEventSeqs` 做范围编码,并在不冻结普通事件词汇或 payload 新增项的前提下校验物理 envelope。v1-to-v2 target validator 会另行冻结 released-v2 清单,current restoration 则使用 installed Session 词汇。冻结的 v0 与 v1 编解码器继续为不可变历史 generation 解码 packed row。
+
+新建 subagent 子项的 constructor seed 与继承的父项前缀完全相同。`Session` 会追加 tagged cut marker,随后 subagent setup 再追加子项持有的 descriptor 与 delegated policy。原 descriptor-seed helper 会被删除,因此 descriptor 绝不会计入继承内容,cold resume 则重放已经持久化的子项 setup。曾把 untagged marker 放在 descriptor 后面的历史 snapshot fixture 会在源处修正;当前比较仍会暴露 marker 数量与序号引用。
+
+`dsh_session_log` request extension 会把外层 schema 升至版本 2,因为它的 Session header 投影会用必需的 `isSeeded` 取代 `seedLength`。其中的 `sessionFormatVersion` 成员仍用于标识嵌入的逻辑 Session generation。
 
 Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-released-session-format-migrations.zh.md):源路径、字节与 inode 保持不变,只发布最终具名版本 successor;保留 predecessor 不提供 fallback 或 downgrade 支持。
 
@@ -43,7 +47,7 @@ Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-rele
 
 紧凑 stream 测试固定 text、reasoning、tool argument、raw chunk、时间戳间隔、格式错误 record 与分离 snapshot 的精确累积和展开。v1 到 v2 测试覆盖成功与失败 attempt、交错、密集序号与引用重映射、seed 切点插入与切分拒绝、严格源与目标校验、每行一个事件的 v2 编码、provenance range、原始与 Zstandard 发布,以及无写入的当前读取。
 
-手工 performance acceptance 会在三轮、100 组 warmup pair 与 600 组 measured pair 下,把当前 v2 catalog dispatch 与同一物理输入的 direct-current 读取比较。它要求每个 pooled median 与 p95 regression 保持在 5% 以内;已接受运行的最差 p95 regression 为 3.150%。`--smoke` 报告不参与 gate 的诊断 sample。
+手工 performance acceptance 会在三轮、100 组 warmup pair 与 600 组 measured pair 下,针对同一批已经解析的物理 row,把静态 catalog routing 与直接 released-v2 restoration 比较;它不比较 v1 与 v2,也不计入 backend I/O。每个 pooled median 与 p95 regression 都必须保持在 5% 以内;已接受运行的最差 p95 regression 为 3.150%。`--smoke` 报告不参与 gate 的诊断 sample。
 
 Agent-loop 测试固定先持久后 end 的顺序、中断的可见前缀、失败与重试 attempt、abandonment、usage 与 replay metadata。Session Controller 与 Conversation 测试固定实时瞬态显示、重连 baseline、committed settlement 发布、历史回放以及 Chat 与 Trajectory 一致性;TypeScript 与 Python SDK snapshot 固定外部事件表示。
 

+ 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: 2bd56cf807ce7433ead009e5f6df73c6151a6664
-config-catalog.zh.md: ef61011aadd14c22c3a8a0fb3d45b029194a166b
+config-catalog.md: 8ff7f5818dd43842b9e934901863c3e9b54656ef
+config-catalog.zh.md: 11a8ef58ed16a6bd7616284c292491fb135aa1e1

+ 1 - 1
docs/config-catalog.md

@@ -1353,7 +1353,7 @@ export interface ReplayModelConfig {
 
 Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-Source: [`packages/test-support/llm-replay/src/index.ts:1388`](../packages/test-support/llm-replay/src/index.ts)
+Source: [`packages/test-support/llm-replay/src/index.ts:1304`](../packages/test-support/llm-replay/src/index.ts)
 
 <a id="deepseek-aidsh-llm-retry"></a>
 

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

@@ -1355,7 +1355,7 @@ export interface ReplayModelConfig {
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/test-support/llm-replay/src/index.ts:1388`](../packages/test-support/llm-replay/src/index.ts)
+来源:[`packages/test-support/llm-replay/src/index.ts:1304`](../packages/test-support/llm-replay/src/index.ts)
 
 <a id="deepseek-aidsh-llm-retry"></a>
 

+ 2 - 2
docs/deepseek-llm-api-wire-extensions.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/deepseek-llm-api-wire-extensions.md
-deepseek-llm-api-wire-extensions.md: 678661ad1794a76e9e872e041415bb2f0f4db682
-deepseek-llm-api-wire-extensions.zh.md: 83b99d957a20fe3068f862c31faf499f6767acae
+deepseek-llm-api-wire-extensions.md: 5b1c4de68949d56f99cbfa70ba4af7ca0b71715d
+deepseek-llm-api-wire-extensions.zh.md: a78baace34dcb3e24667295cf06e111ce355aa72

+ 3 - 3
docs/deepseek-llm-api-wire-extensions.md

@@ -78,7 +78,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
 ```json
 {
   "dsh_session_log": {
-    "version": 1,
+    "version": 2,
     "sessionFormatVersion": 2,
     "session": {
       "version": 2,
@@ -104,7 +104,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
 
 | Member | Type | Meaning |
 |---|---|---|
-| `version` | `1` | Schema version for `dsh_session_log` |
+| `version` | `2` | Schema version for `dsh_session_log` |
 | `sessionFormatVersion` | non-negative integer | Session format generation represented by this suffix |
 | `session` | object | Immutable wire projection of the current Session header |
 | `afterSeq` | integer | Greatest sequence recorded as accepted before this request, or `-1` |
@@ -115,7 +115,7 @@ The first upload uses `afterSeq: -1` and carries the complete current log. Each
 
 ### Wire Session header
 
-The `session` member projects `Session.header`, not a complete runtime Session or the header object itself. It copies the current header facts, including the required `isSeeded` lineage bit; the exact `Session.inheritedEventCount` is not part of this request field. The outer `dsh_session_log.version` selects this extension schema, while `session.version` selects the logical Session format; the two version values evolve independently.
+The `session` member projects `Session.header`, not a complete runtime Session or the header object itself. It copies the current header facts, including the required `isSeeded` lineage bit; the exact `Session.inheritedEventCount` is not part of this request field. The outer `dsh_session_log.version` selects this extension schema, while `session.version` selects the logical Session format. Changing the Session header projection requires an extension-schema bump even when the embedded logical format also changes.
 
 | Member | Presence | Meaning |
 |---|---|---|

+ 3 - 3
docs/deepseek-llm-api-wire-extensions.zh.md

@@ -78,7 +78,7 @@
 ```json
 {
   "dsh_session_log": {
-    "version": 1,
+    "version": 2,
     "sessionFormatVersion": 2,
     "session": {
       "version": 2,
@@ -104,7 +104,7 @@
 
 | 成员 | 类型 | 含义 |
 |---|---|---|
-| `version` | `1` | `dsh_session_log` 的 schema 版本 |
+| `version` | `2` | `dsh_session_log` 的 schema 版本 |
 | `sessionFormatVersion` | 非负整数 | 该后缀所表示的 Session 格式 generation |
 | `session` | 对象 | 当前 Session header 的不可变协议投影 |
 | `afterSeq` | 整数 | 本次请求前记录为已接受的最大序号,或 `-1` |
@@ -115,7 +115,7 @@
 
 ### Session 协议 header
 
-`session` 成员投影 `Session.header`,既不是完整的运行时 Session,也不是 header 对象本身。它复制当前 header 事实,包括必需的 `isSeeded` 谱系位;精确的 `Session.inheritedEventCount` 不属于该请求字段。外层 `dsh_session_log.version` 选择本扩展 schema,`session.version` 则选择逻辑 Session 格式;两个版本值相互独立演进。
+`session` 成员投影 `Session.header`,既不是完整的运行时 Session,也不是 header 对象本身。它复制当前 header 事实,包括必需的 `isSeeded` 谱系位;精确的 `Session.inheritedEventCount` 不属于该请求字段。外层 `dsh_session_log.version` 选择本扩展 schema,`session.version` 则选择逻辑 Session 格式。即使嵌入的逻辑格式同时变化,只要 Session header 投影变化,扩展 schema 也必须升版。
 
 | 成员 | 出现条件 | 含义 |
 |---|---|---|

+ 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: 8b271824dddc9d71a3a160ba1bd034c6cff52ab8
-core.zh.md: 0564d2435a35c7c1f6c8c24851dab497ba967586
+core.md: 8050447620bf882da898dc6d2cec9559716a3d5e
+core.zh.md: 236a17e31c4f7921126b5cf222f63de14f3bd725

+ 10 - 6
docs/subsystems/core.md

@@ -171,9 +171,9 @@ type AssistantStreamFrame =
     readonly revision: number
     /** Dense zero-based position within the attempt. */
     readonly index: number
+    /** Safe-integer timestamp reused by the durable embedded stream. */
+    readonly time: number
     readonly chunk: StreamChunk
-    /** Matching durable v1 `assistant/chunk` record for duplicate suppression. */
-    readonly legacyChunkSeq: SessionSeq
   }
   | {
     readonly type: 'end'
@@ -181,10 +181,14 @@ type AssistantStreamFrame =
     readonly revision: number
     /** Number of chunk frames emitted by this attempt. */
     readonly index: number
-    /** `committed` follows its durable message; `aborted` has no such requirement. */
-    readonly outcome: 'committed' | 'aborted'
-    /** Every durable v1 chunk represented by this attempt. */
-    readonly legacyChunkSeqs: readonly SessionSeq[]
+    /** Durable settlement committed before this notification, or live abandonment without one. */
+    readonly outcome:
+      | {
+        readonly kind: 'committed'
+        readonly eventType: 'assistant/message' | 'assistant/attempt'
+        readonly seq: SessionSeq
+      }
+      | { readonly kind: 'abandoned' }
   }
 ```
 

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

@@ -175,9 +175,9 @@ type AssistantStreamFrame =
     readonly revision: number
     /** Dense zero-based position within the attempt. */
     readonly index: number
+    /** Safe-integer timestamp reused by the durable embedded stream. */
+    readonly time: number
     readonly chunk: StreamChunk
-    /** Matching durable v1 `assistant/chunk` record for duplicate suppression. */
-    readonly legacyChunkSeq: SessionSeq
   }
   | {
     readonly type: 'end'
@@ -185,10 +185,14 @@ type AssistantStreamFrame =
     readonly revision: number
     /** Number of chunk frames emitted by this attempt. */
     readonly index: number
-    /** `committed` follows its durable message; `aborted` has no such requirement. */
-    readonly outcome: 'committed' | 'aborted'
-    /** Every durable v1 chunk represented by this attempt. */
-    readonly legacyChunkSeqs: readonly SessionSeq[]
+    /** Durable settlement committed before this notification, or live abandonment without one. */
+    readonly outcome:
+      | {
+        readonly kind: 'committed'
+        readonly eventType: 'assistant/message' | 'assistant/attempt'
+        readonly seq: SessionSeq
+      }
+      | { readonly kind: 'abandoned' }
   }
 ```
 

+ 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: 0e023521615b00208c90f20a643ec2b87463d42d
-README.zh.md: ddbddff545eec58fa23805c9564cb5f87467b5a6
+README.md: ae900465f46186e5fb9fcdd5a0dceb5a419b3cfe
+README.zh.md: 6bb3ad7804a43ac2869f684e8d120d3ce8de2303

ファイルの差分が大きいため隠しています
+ 0 - 0
packages/api/session-controller/README.md


ファイルの差分が大きいため隠しています
+ 0 - 0
packages/api/session-controller/README.zh.md


+ 22 - 4
packages/api/session-controller/src/client/contract/events.ts

@@ -26,6 +26,11 @@ export type SessionEventLikeEntry =
 
 /** Scalar live entry accepted by append-only Client paths. */
 export type SessionLiveEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'event' }>
+/** Durable Assistant event that atomically supersedes one attempt's transient rows. */
+export interface SessionAssistantSettlementEntry {
+  readonly type: 'event'
+  readonly event: SessionEvent<'assistant/message'> | SessionEvent<'assistant/attempt'>
+}
 /** Client-only Assistant frame admitted outside durable cursor algebra. */
 export type SessionTransientEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'transient' }>
 
@@ -94,6 +99,11 @@ export type SessionEventChange =
   | { readonly kind: 'replace'; readonly entries: readonly SessionEventLikeEntry[] }
   | { readonly kind: 'prepend'; readonly entries: readonly SessionEventLikeEntry[] }
   | { readonly kind: 'append'; readonly entries: readonly SessionEventLikeEntry[] }
+  | {
+    readonly kind: 'settle-assistant'
+    readonly attemptId: LlmAttemptId
+    readonly entry?: SessionAssistantSettlementEntry
+  }
 
 /** Current contiguous event window and its latest synchronous delta. */
 export interface SessionEventWindow {
@@ -168,13 +178,21 @@ export class MutableSessionEventSource implements SessionEventSource {
    * @param attemptId - process-local attempt whose live rows are now redundant.
    * @param entry - durable settlement committed for that attempt.
    */
-  settleAssistant(attemptId: string, entry: SessionLiveEventEntry): void {
+  settleAssistant(attemptId: LlmAttemptId, entry?: SessionAssistantSettlementEntry): void {
     const entries = materialize(this.window).filter(candidate => (
-      candidate.type !== 'transient' || String(candidate.event.data.attemptId) !== attemptId
+      candidate.type !== 'transient' || candidate.event.data.attemptId !== attemptId
     ))
-    entries.push(entry)
+    if (entry !== undefined) {
+      const index = entries.findIndex(candidate => candidate.event.seq > entry.event.seq)
+      if (index < 0) entries.push(entry)
+      else entries.splice(index, 0, entry)
+    }
     this.window = leaf(entries)
-    this.publish(this.snapshot.hasMore, { kind: 'replace', entries })
+    this.publish(this.snapshot.hasMore, {
+      kind: 'settle-assistant',
+      attemptId,
+      ...(entry === undefined ? {} : { entry }),
+    })
   }
 
   private publish(

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

@@ -49,12 +49,15 @@ export type {
 export type { ISessions } from './contract/sessions.ts'
 export { MutableSessionEventSource } from './contract/events.ts'
 export type {
+  AssistantLiveChunkEvent,
+  SessionAssistantSettlementEntry,
   SessionEventChange,
   SessionEventLike,
   SessionEventLikeEntry,
   SessionEventSource,
   SessionEventWindow,
   SessionLiveEventEntry,
+  SessionTransientEventEntry,
 } from './contract/events.ts'
 export type {
   OpenState,

+ 26 - 13
packages/api/session-controller/src/client/sessions/assistant-stream.ts

@@ -6,14 +6,16 @@ import type {
 } from '../../types.ts'
 import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
 import type { AssistantStreamRecord } from '@deepseek-ai/dsh-llm/assistant-stream'
+import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
 import type {
+  SessionAssistantSettlementEntry,
   SessionEventLikeEntry,
   SessionLiveEventEntry,
   SessionTransientEventEntry,
 } from '../contract/events.ts'
 
 interface ActiveAttempt {
-  readonly attemptId: string
+  readonly attemptId: LlmAttemptId
   readonly startedAfterSeq: number
   readonly turn: number
   readonly step: number
@@ -25,9 +27,10 @@ export type ClientAssistantStreamResult =
   | { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
   | {
     readonly type: 'settlement'
-    readonly attemptId: string
-    readonly entry: SessionLiveEventEntry
+    readonly attemptId: LlmAttemptId
+    readonly entry: SessionAssistantSettlementEntry
   }
+  | { readonly type: 'abandonment'; readonly attemptId: LlmAttemptId }
   | { readonly type: 'transient'; readonly entry: SessionTransientEventEntry }
   | { readonly type: 'rebaseline' }
   | undefined
@@ -35,7 +38,7 @@ export type ClientAssistantStreamResult =
 /** Keeps transient Assistant presentation behind one settlement-aware interface. */
 export class ClientAssistantStream {
   private activeAttempt: ActiveAttempt | undefined
-  private readonly pending = new Map<number, SessionLiveEventEntry>()
+  private readonly pending = new Map<number, SessionAssistantSettlementEntry>()
   private publishedSeqs = new Set<number>()
   private durableCursor = -1
   private transientInGap = 0
@@ -56,7 +59,7 @@ export class ClientAssistantStream {
     const opening = baseline?.activeAttempt
     if (opening !== undefined) {
       this.activeAttempt = {
-        attemptId: String(opening.attemptId),
+        attemptId: opening.attemptId,
         startedAfterSeq: opening.startedAfterSeq,
         turn: opening.turn,
         step: opening.step,
@@ -100,9 +103,10 @@ export class ClientAssistantStream {
     const event = entry.event
     this.durableCursor = Math.max(this.durableCursor, event.seq)
     this.transientInGap = 0
-    if (this.attemptForSettlement(event) !== undefined) {
+    const settlement = assistantSettlementEntry(entry)
+    if (settlement !== undefined && this.attemptForSettlement(settlement.event) !== undefined) {
       if (this.pending.has(event.seq)) return { type: 'rebaseline' }
-      this.pending.set(event.seq, entry)
+      this.pending.set(event.seq, settlement)
       return undefined
     }
     return this.publish(entry)
@@ -119,7 +123,7 @@ export class ClientAssistantStream {
         if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' }
         this.pending.clear()
         this.activeAttempt = {
-          attemptId: String(frame.attemptId),
+          attemptId: frame.attemptId,
           startedAfterSeq: frame.startedAfterSeq,
           turn: frame.turn,
           step: frame.step,
@@ -131,7 +135,7 @@ export class ClientAssistantStream {
         // A controller mounted after the Host saw this attempt has no start
         // frame to reconstruct. Its durable settlement publishes directly;
         // ignore the transient suffix until the next known start.
-        if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) return undefined
+        if (attempt === undefined || attempt.attemptId !== frame.attemptId) return undefined
         if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
         attempt.nextIndex += 1
         this.transientInGap += 1
@@ -155,13 +159,15 @@ export class ClientAssistantStream {
       }
       case 'end': {
         const attempt = this.activeAttempt
-        if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) {
+        if (attempt === undefined || attempt.attemptId !== frame.attemptId) {
           return undefined
         }
         this.activeAttempt = undefined
         if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
         if (frame.outcome.kind === 'abandoned') {
-          return this.pending.size === 0 ? undefined : { type: 'rebaseline' }
+          return this.pending.size === 0
+            ? { type: 'abandonment', attemptId: attempt.attemptId }
+            : { type: 'rebaseline' }
         }
         if (this.publishedSeqs.has(frame.outcome.seq)) return undefined
         const entry = this.pending.get(frame.outcome.seq)
@@ -177,11 +183,10 @@ export class ClientAssistantStream {
   }
 
   private attemptForSettlement(
-    event: SessionLiveEventEntry['event'],
+    event: SessionAssistantSettlementEntry['event'],
   ): ActiveAttempt | undefined {
     const attempt = this.activeAttempt
     if (attempt === undefined
-      || (event.type !== 'assistant/message' && event.type !== 'assistant/attempt')
       || (event.type === 'assistant/message' && event.surfaceOp !== 'append')
       || event.seq <= attempt.startedAfterSeq
       || attempt.turn !== event.data.turn
@@ -194,3 +199,11 @@ export class ClientAssistantStream {
     return { type: 'publish', entry }
   }
 }
+
+function assistantSettlementEntry(
+  entry: SessionLiveEventEntry,
+): SessionAssistantSettlementEntry | undefined {
+  return entry.event.type === 'assistant/message' || entry.event.type === 'assistant/attempt'
+    ? entry as SessionAssistantSettlementEntry
+    : undefined
+}

+ 5 - 0
packages/api/session-controller/src/client/sessions/session.ts

@@ -674,6 +674,11 @@ export class Session implements SessionFace {
       this.notifier.markDirty()
       return
     }
+    if (result?.type === 'abandonment') {
+      this.eventSource.settleAssistant(result.attemptId)
+      this.notifier.markDirty()
+      return
+    }
     if (result?.type === 'publish' && this.appendLive(result.entry)) {
       this.notifier.markDirty()
     } else if (result?.type === 'transient') {

+ 10 - 9
packages/api/session-controller/tests/assistant-stream.client.spec.ts

@@ -112,14 +112,12 @@ describe('ClientAssistantStream', () => {
     const visible = stream.replace([durable], baseline(1))
 
     expect(visible[0]).toBe(durable)
-    expect(visible.slice(1)).toEqual([expect.objectContaining({
-      type: 'transient',
-      event: expect.objectContaining({
-        type: 'assistant/live-chunk',
-        seq: 4.5,
-        time: 20,
-      }),
-    })])
+    expect(visible).toHaveLength(2)
+    const reconstructed = visible[1]
+    if (reconstructed?.type !== 'transient') throw new Error('expected reconstructed transient chunk')
+    expect(reconstructed.event.type).toBe('assistant/live-chunk')
+    expect(reconstructed.event.seq).toBe(4.5)
+    expect(reconstructed.event.time).toBe(20)
 
     expect(stream.replace([], baseline(3))).toHaveLength(2)
     expect(stream.replace([])).toEqual([])
@@ -183,7 +181,10 @@ describe('ClientAssistantStream', () => {
 
   it('settles abandonment only when no durable settlement remains pending', () => {
     const empty = opened()
-    expect(empty.acceptFrame(end(0, { kind: 'abandoned' }))).toBeUndefined()
+    expect(empty.acceptFrame(end(0, { kind: 'abandoned' }))).toEqual({
+      type: 'abandonment',
+      attemptId: String(ATTEMPT),
+    })
 
     const pending = opened()
     expect(pending.acceptDurable(attemptEvent(2))).toBeUndefined()

+ 62 - 1
packages/api/session-controller/tests/client-contract.client.spec.ts

@@ -2,8 +2,10 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
 import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types'
 import { SessionSeq, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
 import {
-  MutableSessionEventSource, type SessionLiveEventEntry,
+  MutableSessionEventSource, type SessionAssistantSettlementEntry,
+  type SessionLiveEventEntry, type SessionTransientEventEntry,
 } from '../src/client/contract/events.ts'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
 import type { ISession } from '../src/client/contract/session.ts'
 import type { ProjectionsBaseline } from '../src/client/sessions/projection-store.ts'
 import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
@@ -24,6 +26,35 @@ function entry(seq: SessionSeq): SessionLiveEventEntry {
   }
 }
 
+function transient(attemptId: string, seq = 1.5): SessionTransientEventEntry {
+  return {
+    type: 'transient',
+    event: {
+      type: 'assistant/live-chunk',
+      seq,
+      time: 1,
+      data: {
+        attemptId: LlmAttemptId(attemptId),
+        turn: 1,
+        step: 1,
+        chunk: { type: 'text-delta', index: 0, text: 'live' },
+      },
+    },
+  }
+}
+
+function assistantSettlement(seq: SessionSeq): SessionAssistantSettlementEntry {
+  return {
+    type: 'event',
+    event: {
+      type: 'assistant/attempt',
+      seq,
+      time: seq,
+      data: { turn: 1, step: 1, stream: [] },
+    },
+  }
+}
+
 describe('Client Session contracts', () => {
   it('requires branded Session positions at internal event fixture boundaries', () => {
     expectTypeOf(entry).parameter(0).toEqualTypeOf<SessionSeq>()
@@ -105,4 +136,34 @@ describe('Client Session contracts', () => {
     expect(iterate).toHaveBeenCalledOnce()
   })
 
+  it('publishes one exact Assistant settlement delta after retiring its transient rows', () => {
+    const feed = new MutableSessionEventSource()
+    const opening = entry(SessionSeq(1))
+    const live = transient('attempt-one')
+    const settlement = assistantSettlement(SessionSeq(2))
+    const later = entry(SessionSeq(3))
+    feed.replace([opening, live, later], false)
+
+    feed.settleAssistant(LlmAttemptId('attempt-one'), settlement)
+
+    expect(feed.getSnapshot()).toEqual({
+      entries: [opening, settlement, later],
+      hasMore: false,
+      revision: 2,
+      change: {
+        kind: 'settle-assistant',
+        attemptId: 'attempt-one',
+        entry: settlement,
+      },
+    })
+
+    feed.append(transient('attempt-two', 3.5))
+    feed.settleAssistant(LlmAttemptId('attempt-two'))
+    expect(feed.getSnapshot().entries).toEqual([opening, settlement, later])
+    expect(feed.getSnapshot().change).toEqual({
+      kind: 'settle-assistant',
+      attemptId: 'attempt-two',
+    })
+  })
+
 })

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

@@ -335,7 +335,7 @@ describe('scope tree', () => {
         .toEqual(['assistant/message', 'assistant/message'])
     })
     expect(binding.eventSource.getSnapshot().change).toEqual({
-      kind: 'replace', entries: [priorMessage, currentMessage],
+      kind: 'settle-assistant', attemptId: String(attemptId), entry: currentMessage,
     })
   })
 

+ 1 - 0
packages/client/connection/package.json

@@ -48,6 +48,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",

+ 15 - 6
packages/client/connection/src/client/fixture.ts

@@ -26,6 +26,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
 import type {
   SessionEvent,
   SessionId,
+  SessionSeqCursor,
 } from '@deepseek-ai/dsh-session/types'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -166,13 +167,14 @@ interface FixtureAssistantStreamBaseline {
   }
 }
 
-type FixtureAssistantStreamFrame =
+/** Assistant frame emitted by the standalone fixture; tests pin it to the controller wire type. */
+export type FixtureAssistantStreamFrame =
   | {
     readonly type: 'start'
     readonly attemptId: ReturnType<typeof LlmAttemptId>
     readonly revision: number
     readonly startedTime: number
-    readonly startedAfterSeq: number
+    readonly startedAfterSeq: SessionSeqCursor
     readonly turn: number
     readonly step: number
   }
@@ -182,14 +184,19 @@ type FixtureAssistantStreamFrame =
     readonly revision: number
     readonly index: number
     readonly time: number
-    readonly chunk: StreamChunk
+    readonly chunk: JsonValue
   }
   | {
     readonly type: 'end'
     readonly attemptId: ReturnType<typeof LlmAttemptId>
     readonly revision: number
+    readonly index: number
     readonly outcome:
-      | { readonly kind: 'committed'; readonly eventType: 'assistant/message' | 'assistant/attempt'; readonly seq: number }
+      | {
+        readonly kind: 'committed'
+        readonly eventType: 'assistant/message' | 'assistant/attempt'
+        readonly seq: number
+      }
       | { readonly kind: 'abandoned' }
   }
 
@@ -2081,7 +2088,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
   const beginAssistant = (sessionId: SessionId, turn: number, step: number): FixtureAttemptState => {
     const attemptId = LlmAttemptId(`${sessionId}:fixture:${String(nextAssistantRevision(sessionId))}`)
     const startedTime = Date.now()
-    const startedAfterSeq = logOf(sessionId).length - 1
+    const lastSeq = logOf(sessionId).length - 1
+    const startedAfterSeq = lastSeq < 0 ? -1 : SessionSeq(lastSeq)
     const attempt = {
       attemptId, startedTime, startedAfterSeq, turn, step,
       stream: new AssistantStreamAccumulator(), index: 0,
@@ -2099,7 +2107,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     const timed = attempt.stream.push({ time: Date.now(), chunk })
     emitAssistant(sessionId, {
       type: 'chunk', attemptId: attempt.attemptId, revision: nextAssistantRevision(sessionId),
-      index: attempt.index++, time: timed.time, chunk: timed.chunk,
+      index: attempt.index++, time: timed.time, chunk: timed.chunk as unknown as JsonValue,
     })
   }
   const commitAssistant = (sessionId: SessionId, event: SessionEvent): void => {
@@ -2108,6 +2116,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     activeAttempts.delete(sessionId)
     emitAssistant(sessionId, {
       type: 'end', attemptId: attempt.attemptId, revision: nextAssistantRevision(sessionId),
+      index: attempt.index,
       outcome: {
         kind: 'committed',
         eventType: event.type === 'assistant/message' ? 'assistant/message' : 'assistant/attempt',

+ 25 - 33
packages/client/connection/tests/fixture.client.spec.ts

@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
 import type {
   RpcRequest,
   RpcResponse,
@@ -11,14 +11,16 @@ import { RpcId } from '../src/client/api.ts'
 import {
   createFixtureConnectionRpc,
   createFixtureFaces,
+  type FixtureAssistantStreamFrame,
   type FixtureOptions,
 } from '../src/client/fixture.ts'
 import type {
   ClientConnectionRpc, ConnectionRpcResult,
 } from '../src/rpc.ts'
 import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
-import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types'
-import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
+import type {
+  ModelCatalog, ModelSelection, SessionAssistantStreamFrame,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 
 const sid = (id: string): SessionId => id as SessionId
 type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' }
@@ -52,32 +54,13 @@ function historyEvents(records: readonly FixtureHistoryRecord[]): SessionEvent[]
   return records.map(record => record.event)
 }
 
-type FixtureAssistantStreamFrame =
-  | {
-    readonly type: 'start'
-    readonly attemptId: string
-    readonly revision: number
-    readonly startedTime: number
-    readonly startedAfterSeq: number
-    readonly turn: number
-    readonly step: number
-  }
-  | {
-    readonly type: 'chunk'
-    readonly attemptId: string
-    readonly revision: number
-    readonly index: number
-    readonly time: number
-    readonly chunk: StreamChunk
-  }
-  | {
-    readonly type: 'end'
-    readonly attemptId: string
-    readonly revision: number
-    readonly outcome:
-      | { readonly kind: 'committed'; readonly eventType: 'assistant/message' | 'assistant/attempt'; readonly seq: number }
-      | { readonly kind: 'abandoned' }
-  }
+function isReasoningDeltaChunk(
+  value: unknown,
+): value is Extract<StreamChunk, { readonly type: 'reasoning-delta' }> {
+  if (typeof value !== 'object' || value === null) return false
+  const record = value as { readonly type?: unknown; readonly text?: unknown }
+  return record.type === 'reasoning-delta' && typeof record.text === 'string'
+}
 
 type FixtureFollowFrame =
   | {
@@ -616,6 +599,10 @@ async function readWorkspaceBaseline(
 }
 
 describe('createFixtureApi', () => {
+  it('keeps its Assistant frame vocabulary identical to the controller wire', () => {
+    expectTypeOf<FixtureAssistantStreamFrame>().toEqualTypeOf<SessionAssistantStreamFrame>()
+  })
+
   it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => {
     const api = createFixtureApi()
     const request = req({})
@@ -907,9 +894,14 @@ describe('createFixtureApi', () => {
     expect(types).toContain('user/message')
     expect(types).toContain('assistant/message')
     expect(assistantFrames.some(frame => frame.type === 'chunk')).toBe(true)
-    expect(assistantFrames.some(frame => frame.type === 'end'
+    const committed = assistantFrames.find(frame => frame.type === 'end'
       && frame.outcome.kind === 'committed'
-      && frame.outcome.eventType === 'assistant/message')).toBe(true)
+      && frame.outcome.eventType === 'assistant/message')
+    expect(committed).toMatchObject({
+      type: 'end',
+      index: assistantFrames.filter(frame => frame.type === 'chunk'
+        && frame.attemptId === committed?.attemptId).length,
+    })
     expect(types.at(-1)).toBe('turn/end')
     // Capacity is durable log state, not a transient frame: the prompt path
     // records request/context and the projection carries it to the client.
@@ -1603,7 +1595,7 @@ describe('createFixtureApi', () => {
       const streamed = collectValues(api.sessionRemote.follow(sid('fx-alpha'), abort.signal), abort, frames => frames.some(frame => (
         frame.type === 'assistant-stream'
         && frame.frame.type === 'chunk'
-        && frame.frame.chunk.type === 'reasoning-delta'
+        && isReasoningDeltaChunk(frame.frame.chunk)
         && frame.frame.chunk.text.includes('REASONING_STRESS_COMPLETE')
       )))
       const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
@@ -1622,7 +1614,7 @@ describe('createFixtureApi', () => {
       const deltas = frames.flatMap(frame => (
         frame.type === 'assistant-stream'
         && frame.frame.type === 'chunk'
-        && frame.frame.chunk.type === 'reasoning-delta'
+        && isReasoningDeltaChunk(frame.frame.chunk)
           ? [frame.frame.chunk.text]
           : []
       ))

+ 1 - 1
packages/client/tsdown.client.ts

@@ -58,7 +58,7 @@ function styleInjectionModule(
  * Everything else under @deepseek-ai/* is either a module-table entry
  * (external) or a leak the purity gate rejects.
  */
-export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|timeout|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$)/
+export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$)/
 
 /**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below

+ 101 - 11
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -11,6 +11,8 @@ import {
   type ConversationViewDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
 import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
 import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
@@ -84,7 +86,64 @@ function at(
 }
 
 function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
-  return [...entries]
+  const output: SessionEventLikeEntry[] = []
+  let active: {
+    readonly turn: number
+    readonly step: number
+    readonly stream: AssistantStreamAccumulator
+    last: SessionLiveEventEntry
+  } | undefined
+  const flush = (): void => {
+    if (active === undefined) return
+    output.push(at(active.last.event.seq, 'assistant/attempt', {
+      turn: active.turn,
+      step: active.step,
+      stream: active.stream.snapshot(),
+    }, { time: active.last.event.time }))
+    active = undefined
+  }
+  for (const entry of entries) {
+    const event = entry.event as unknown as {
+      readonly type: string
+      readonly time: number
+      readonly data: { readonly turn?: number; readonly step?: number; readonly chunk?: StreamChunk }
+    }
+    if (event.type === 'assistant/live-chunk'
+      && event.data.turn !== undefined
+      && event.data.step !== undefined
+      && event.data.chunk !== undefined) {
+      if (active !== undefined && (active.turn !== event.data.turn || active.step !== event.data.step)) flush()
+      const current = active ?? {
+        turn: event.data.turn,
+        step: event.data.step,
+        stream: new AssistantStreamAccumulator(),
+        last: entry,
+      }
+      active = current
+      current.stream.push({ time: event.time, chunk: event.data.chunk })
+      current.last = entry
+      continue
+    }
+    const current = active
+    if (event.type === 'assistant/message'
+      && current !== undefined
+      && current.turn === event.data.turn
+      && current.step === event.data.step) {
+      output.push({
+        ...entry,
+        event: {
+          ...entry.event,
+          data: { ...entry.event.data, stream: current.stream.snapshot() },
+        } as SessionEvent,
+      })
+      active = undefined
+      continue
+    }
+    flush()
+    output.push(entry)
+  }
+  flush()
+  return output
 }
 
 function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
@@ -135,6 +194,24 @@ function comparableSnapshot(value: ChatSnapshot) {
   }
 }
 
+function withoutEmbeddedSequenceAnchors(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(withoutEmbeddedSequenceAnchors)
+  if (value instanceof Map) {
+    return new Map([...value].map(([key, entry]) => [key, withoutEmbeddedSequenceAnchors(entry)]))
+  }
+  if (typeof value !== 'object' || value === null) return value
+  return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
+    key,
+    key === 'anchorSeq' || key === 'controlAnchorSeq' || key === 'processStartSeq'
+      ? '<representation-owned-seq>'
+      : withoutEmbeddedSequenceAnchors(entry),
+  ]))
+}
+
+function comparableEmbeddedSnapshot(value: ChatSnapshot): unknown {
+  return withoutEmbeddedSequenceAnchors(comparableSnapshot(value))
+}
+
 function textMessage(id: string, text: string) {
   return {
     id,
@@ -877,7 +954,7 @@ describe('built-in conversation node Definitions', () => {
     })
   })
 
-  it('folds packed Assistant runs to the same Chat and Turn Tail state as scalar deltas', () => {
+  it('folds packed Assistant runs to the same Chat content and Turn Tail state as scalar deltas', () => {
     const runningHistory = [
       at(1, 'turn/start', { turn: 1 }),
       at(2, 'step/start', { turn: 1, step: 1 }),
@@ -917,12 +994,16 @@ describe('built-in conversation node Definitions', () => {
     ]
     const scalar = assembler(runningHistory)
     const packedHistory = packedInputs(runningHistory)
-    expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(0)
+    expect(packedHistory).toHaveLength(3)
+    const runningAttempt = packedHistory.at(-1)?.event
+    expect(runningAttempt?.type).toBe('assistant/attempt')
+    if (runningAttempt?.type !== 'assistant/attempt') throw new Error('expected packed running attempt')
+    expect(runningAttempt.data.stream.length).toBeGreaterThan(0)
     const packed = assembler(packedHistory)
 
-    expect(comparableSnapshot(snapshot(packed))).toEqual(comparableSnapshot(snapshot(scalar)))
+    expect(comparableEmbeddedSnapshot(snapshot(packed))).toEqual(comparableEmbeddedSnapshot(snapshot(scalar)))
     const running = node(snapshot(packed), 'assistant-step')
-    expect(running).toMatchObject({ anchorSeq: 6 })
+    expect(running).toMatchObject({ anchorSeq: 12 })
     expect(running?.data).toMatchObject({
       time: 1_004,
       blocks: [
@@ -937,7 +1018,7 @@ describe('built-in conversation node Definitions', () => {
       value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
       value.flush()
     }
-    expect(comparableSnapshot(snapshot(packed))).toEqual(comparableSnapshot(snapshot(scalar)))
+    expect(comparableEmbeddedSnapshot(snapshot(packed))).toEqual(comparableEmbeddedSnapshot(snapshot(scalar)))
     expect(node(snapshot(packed), 'turn-tail')?.anchorSeq).toBe(12.2)
 
     const partialHistory = [
@@ -947,7 +1028,7 @@ describe('built-in conversation node Definitions', () => {
     ]
     const partialScalar = snapshot(assembler(partialHistory, true))
     const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
-    expect(comparableSnapshot(partialPacked)).toEqual(comparableSnapshot(partialScalar))
+    expect(comparableEmbeddedSnapshot(partialPacked)).toEqual(comparableEmbeddedSnapshot(partialScalar))
     expect(node(partialPacked, 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
     expect(node(partialPacked, 'turn-tail')?.anchorSeq).toBe(12.2)
 
@@ -982,8 +1063,13 @@ describe('built-in conversation node Definitions', () => {
       }, { surfaceOp: 'append' }),
     ]
     const finalizedScalar = snapshot(assembler(finalizedHistory))
-    const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
-    expect(comparableSnapshot(finalizedPacked)).toEqual(comparableSnapshot(finalizedScalar))
+    const finalizedInputs = packedInputs(finalizedHistory)
+    expect(finalizedInputs.filter(input => input.event.type === 'assistant/attempt')).toHaveLength(1)
+    const finalizedMessage = finalizedInputs.find(input => input.event.type === 'assistant/message')?.event
+    if (finalizedMessage?.type !== 'assistant/message') throw new Error('expected packed final message')
+    expect(finalizedMessage.data.stream.length).toBeGreaterThan(0)
+    const finalizedPacked = snapshot(assembler(finalizedInputs))
+    expect(comparableEmbeddedSnapshot(finalizedPacked)).toEqual(comparableEmbeddedSnapshot(finalizedScalar))
     const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(finalNode?.timing?.firstTokenTime).toBe(1_999)
 
@@ -1004,8 +1090,12 @@ describe('built-in conversation node Definitions', () => {
       }, { surfaceOp: 'append' }),
     ]
     const namedToolScalar = snapshot(assembler(namedToolHistory))
-    const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
-    expect(comparableSnapshot(namedToolPacked)).toEqual(comparableSnapshot(namedToolScalar))
+    const namedToolInputs = packedInputs(namedToolHistory)
+    const namedToolMessage = namedToolInputs.find(input => input.event.type === 'assistant/message')?.event
+    if (namedToolMessage?.type !== 'assistant/message') throw new Error('expected packed named-tool message')
+    expect(namedToolMessage.data.stream.length).toBeGreaterThan(0)
+    const namedToolPacked = snapshot(assembler(namedToolInputs))
+    expect(comparableEmbeddedSnapshot(namedToolPacked)).toEqual(comparableEmbeddedSnapshot(namedToolScalar))
     const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(namedTool?.timing?.firstTokenTime).toBe(4_000)
   })

+ 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: 3292c39477a051ac406f453e781376164333ee78
-README.zh.md: 433b2e6c8b3580dadf5dd9e38eaea7b1ab7022a6
+README.md: dabe5afda6554a62f5ca038952e5a02e5a2f712e
+README.zh.md: c38ea211c5d8d0ea384cfa39eba2608571166c89

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

@@ -27,7 +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 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`.
+The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. 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 - 1
packages/client/ui-conversation/README.zh.md

@@ -27,7 +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 把每个 `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` 区分持久 event 与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。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。
 

+ 45 - 1
packages/client/ui-conversation/src/client/conversation/assembler.ts

@@ -1,4 +1,7 @@
-import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
+import type {
+  SessionAssistantSettlementEntry, SessionEventLikeEntry, SessionTransientEventEntry,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
 import type {
   ConversationContextReader, ConversationLocationData, ConversationMatch,
   ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
@@ -239,6 +242,47 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
     return publication
   }
 
+  /**
+   * Retire one Assistant attempt's transient matches and apply its optional durable settlement.
+   * @param attemptId - process-local attempt whose transient presentation ended.
+   * @param entry - durable message or attempt event committed for the stream.
+   * @returns highest requested publication cadence.
+   */
+  settleAssistant(
+    attemptId: LlmAttemptId,
+    entry?: SessionAssistantSettlementEntry,
+  ): ConversationPublication {
+    this.revised.clear()
+    const retired = [...this.inputs.values()].filter((candidate): candidate is SessionTransientEventEntry => (
+      candidate.type === 'transient'
+      && candidate.event.data.attemptId === attemptId
+    ))
+    const retiredSeqs = new Set(retired.map(candidate => candidate.event.seq))
+    const affected = new Set<InternalContext>()
+    for (const seq of retiredSeqs) {
+      this.inputs.delete(seq)
+      for (const context of this.contextsBySeq.get(seq) ?? []) affected.add(context)
+      this.contextsBySeq.delete(seq)
+    }
+    for (const context of affected) {
+      context.matches = context.matches.filter(match => !retiredSeqs.has(match.event.seq))
+    }
+    this.locationIndex.removeAssistantTransients(retired.map(candidate => candidate.event))
+
+    let publication: ConversationPublication = retired.length === 0 ? 'none' : 'immediate'
+    if (entry !== undefined && !this.inputs.has(entry.event.seq)) {
+      this.inputs.set(entry.event.seq, entry)
+      this.locationIndex.insertAssistantSettlement(entry.event)
+      const pending = new Map<string, PendingMatch[]>()
+      publication = maximumPublication(publication, this.collectInput(entry, pending))
+      this.applyPendingMatches(pending, affected)
+    }
+    this.replayContexts(affected)
+    if (this.replayRevisedDependents()) publication = 'immediate'
+    this.revised.clear()
+    return publication
+  }
+
   /**
    * Add an older page while preserving existing Context and view identities.
    * @param entries - newly loaded older Events.

+ 7 - 0
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -113,7 +113,14 @@ class BoundConversation implements ConversationBinding {
           if (next === 'immediate' || publication === 'none') publication = next
         }
         this.publish(publication)
+        return
       }
+      case 'settle-assistant':
+        this.publish(this.assembler.settleAssistant(
+          window.change.attemptId,
+          window.change.entry,
+        ))
+        return
     }
   }
 

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

@@ -1,5 +1,5 @@
 import {
-  type SessionEventLike, type SessionEventLikeEntry,
+  type AssistantLiveChunkEvent, type SessionEventLike, type SessionEventLikeEntry,
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -525,6 +525,38 @@ export class ConversationLocationIndex {
     this.locations.set(event.seq, this.resolve(event.seq))
   }
 
+  /**
+   * Remove indexed Assistant transients without rebuilding the Turn/Step timeline.
+   * @param events - transient events retired by one Assistant settlement.
+   */
+  removeAssistantTransients(events: readonly AssistantLiveChunkEvent[]): void {
+    for (const event of events) {
+      const turn = this.coordinates.get(event.seq)?.turn
+      if (turn !== undefined) {
+        const seqs = this.seqsByTurn.get(turn)
+        seqs?.delete(event.seq)
+        if (seqs?.size === 0) this.seqsByTurn.delete(turn)
+      }
+      this.coordinates.delete(event.seq)
+      this.locations.delete(event.seq)
+    }
+  }
+
+  /**
+   * Index one durable Assistant settlement inserted before an already visible tail.
+   * @param event - message or attempt settlement with explicit Turn and Step coordinates.
+   */
+  insertAssistantSettlement(
+    event: SessionEvent<'assistant/message'> | SessionEvent<'assistant/attempt'>,
+  ): void {
+    this.coordinates.set(event.seq, {
+      turn: event.data.turn,
+      step: event.data.step,
+    })
+    this.indexTurnSeq(event.data.turn, event.seq)
+    this.locations.set(event.seq, this.resolve(event.seq))
+  }
+
   private indexTurnSeq(turn: number, seq: number): void {
     const current = this.seqsByTurn.get(turn) ?? new Set<number>()
     current.add(seq)

+ 68 - 0
packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts

@@ -509,6 +509,74 @@ describe('ConversationNodeAssembler', () => {
     ))).toHaveLength(1)
   })
 
+  it('settles one Assistant attempt without replacing unrelated Contexts', () => {
+    interface State { readonly events: readonly string[] }
+    const definition: ConversationNodeDefinition<State> = {
+      kind: 'assistant-settlement',
+      target: 'test',
+      match: (event) => {
+        if (event.type === 'step/start') {
+          return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
+        }
+        if (event.type === 'assistant/live-chunk'
+          || event.type === 'assistant/message'
+          || event.type === 'assistant/attempt'
+          || event.type === 'llm/retry') {
+          return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
+        }
+        return null
+      },
+      start: () => ({ events: [] }),
+      update: (context, match) => ({
+        events: [...context.state.events, match.event.type],
+      }),
+      buildViewNode: context => context.state === undefined
+        ? null
+        : node(context, context.state.events),
+    }
+    const apply = vi.fn()
+    const assembler = new ConversationNodeAssembler(
+      new TestEventDefinitions([definition]),
+      new TestViewDefinitions([testView(apply)]),
+    )
+    const firstStart = input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 }))
+    const secondStart = input(at(SessionSeq(11), 'step/start', { turn: 2, step: 4 }))
+    const delta = transientChunk(11.5, 2, 3, { type: 'text-delta', index: 0, text: 'abc' })
+    const later = input(at(SessionSeq(13), 'llm/retry', { turn: 2, step: 3 }))
+    assembler.replaceWindow([firstStart, secondStart, delta, later], false)
+    assembler.flush()
+    const before = [...testSnapshot(assembler)?.nodes.values() ?? []]
+    const unaffected = before.find(candidate => candidate.id === '2:4')
+    expect(unaffected).toBeDefined()
+    apply.mockClear()
+    const settlementEvent = at(SessionSeq(12), 'assistant/message', {
+      turn: 2,
+      step: 3,
+      message: { role: 'assistant', content: [], source: { kind: 'model', provider: 'p', model: 'm' } },
+      stream: [],
+    })
+    if (settlementEvent.type !== 'assistant/message') throw new Error('expected Assistant settlement')
+    const settlement = { type: 'event' as const, event: settlementEvent }
+
+    expect(assembler.settleAssistant(LlmAttemptId('test-attempt'), settlement)).toBe('immediate')
+    assembler.flush()
+
+    const after = [...testSnapshot(assembler)?.nodes.values() ?? []]
+    expect(after.find(candidate => candidate.id === '2:3')?.data)
+      .toEqual(['assistant/message', 'llm/retry'])
+    expect(after.find(candidate => candidate.id === '2:4')).toBe(unaffected)
+    expect(apply).toHaveBeenCalledOnce()
+    expect(apply.mock.calls[0]?.[0]).toHaveLength(1)
+
+    assembler.append(transientChunk(13.5, 2, 3, { type: 'reasoning-delta', index: 0, text: 'x' }))
+    assembler.flush()
+    expect(assembler.settleAssistant(LlmAttemptId('test-attempt'))).toBe('immediate')
+    assembler.flush()
+    expect([...testSnapshot(assembler)?.nodes.values() ?? []]
+      .find(candidate => candidate.id === '2:3')?.data)
+      .toEqual(['assistant/message', 'llm/retry'])
+  })
+
   it('replays one pending transient Match after prepend supplies its durable start', () => {
     const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
     const updates = vi.fn((

+ 20 - 8
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -1,8 +1,8 @@
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
-import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
-import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createAssistantMessage, LlmAttemptId } from '@deepseek-ai/dsh-llm'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import {
   createScope, MutableSessionEventSource,
@@ -237,12 +237,24 @@ describe('Conversation registries', () => {
         turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'c' },
       },
     })
-    append({
-      seq: SessionSeq(5),
-      time: 5,
-      type: 'assistant/message',
-      data: { turn: 1, step: 1, message: { id: 'a1', role: 'assistant', content: [] }, stream: [] },
-    } as SessionEvent)
+    source.settleAssistant(LlmAttemptId('frame-probe'), {
+      type: 'event',
+      event: {
+        seq: SessionSeq(5),
+        time: 5,
+        type: 'assistant/message',
+        data: {
+          turn: 1,
+          step: 1,
+          message: createAssistantMessage({
+            content: [],
+            source: { provider: 'test', model: 'test' },
+          }),
+          stream: [],
+        },
+        surfaceOp: 'append',
+      },
+    })
     expect(cancelFrame).toHaveBeenCalledWith(4)
     expect(frames).toHaveLength(0)
     expect(listener).toHaveBeenCalledTimes(2)

+ 76 - 4
packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts

@@ -8,6 +8,8 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { ConversationNodeAssembler, inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
 import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
 import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
@@ -73,7 +75,64 @@ function at(
 }
 
 function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
-  return [...entries]
+  const output: SessionEventLikeEntry[] = []
+  let active: {
+    readonly turn: number
+    readonly step: number
+    readonly stream: AssistantStreamAccumulator
+    last: SessionLiveEventEntry
+  } | undefined
+  const flush = (): void => {
+    if (active === undefined) return
+    output.push(at(active.last.event.seq, 'assistant/attempt', {
+      turn: active.turn,
+      step: active.step,
+      stream: active.stream.snapshot(),
+    }, { time: active.last.event.time }))
+    active = undefined
+  }
+  for (const entry of entries) {
+    const event = entry.event as unknown as {
+      readonly type: string
+      readonly time: number
+      readonly data: { readonly turn?: number; readonly step?: number; readonly chunk?: StreamChunk }
+    }
+    if (event.type === 'assistant/live-chunk'
+      && event.data.turn !== undefined
+      && event.data.step !== undefined
+      && event.data.chunk !== undefined) {
+      if (active !== undefined && (active.turn !== event.data.turn || active.step !== event.data.step)) flush()
+      const current = active ?? {
+        turn: event.data.turn,
+        step: event.data.step,
+        stream: new AssistantStreamAccumulator(),
+        last: entry,
+      }
+      active = current
+      current.stream.push({ time: event.time, chunk: event.data.chunk })
+      current.last = entry
+      continue
+    }
+    const current = active
+    if (event.type === 'assistant/message'
+      && current !== undefined
+      && current.turn === event.data.turn
+      && current.step === event.data.step) {
+      output.push({
+        ...entry,
+        event: {
+          ...entry.event,
+          data: { ...entry.event.data, stream: current.stream.snapshot() },
+        } as SessionEvent,
+      })
+      active = undefined
+      continue
+    }
+    flush()
+    output.push(entry)
+  }
+  flush()
+  return output
 }
 
 function assembler(events: readonly SessionEventLikeEntry[]): ConversationNodeAssembler {
@@ -202,7 +261,11 @@ describe('Trajectory conversation Definitions', () => {
     ]
     const runningScalar = snapshot(assembler(runningHistory))
     const packedHistory = packedInputs(runningHistory)
-    expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(0)
+    expect(packedHistory).toHaveLength(3)
+    const runningAttempt = packedHistory.at(-1)?.event
+    expect(runningAttempt?.type).toBe('assistant/attempt')
+    if (runningAttempt?.type !== 'assistant/attempt') throw new Error('expected packed running attempt')
+    expect(runningAttempt.data.stream.length).toBeGreaterThan(0)
     const runningPacked = snapshot(assembler(packedHistory))
     expect(runningPacked).toEqual(runningScalar)
     expect(runningPacked.partial?.blocks).toEqual([
@@ -263,7 +326,12 @@ describe('Trajectory conversation Definitions', () => {
       at(31, 'step/end', { turn: 2, step: 1 }),
     ]
     const finalizedScalar = snapshot(assembler(finalizedHistory))
-    const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
+    const finalizedInputs = packedInputs(finalizedHistory)
+    expect(finalizedInputs.filter(input => input.event.type === 'assistant/attempt')).toHaveLength(1)
+    const finalizedMessage = finalizedInputs.find(input => input.event.type === 'assistant/message')?.event
+    if (finalizedMessage?.type !== 'assistant/message') throw new Error('expected packed final message')
+    expect(finalizedMessage.data.stream.length).toBeGreaterThan(0)
+    const finalizedPacked = snapshot(assembler(finalizedInputs))
     expect(finalizedPacked).toEqual(finalizedScalar)
     expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
       timing: { firstTokenTime: 3_000 },
@@ -291,7 +359,11 @@ describe('Trajectory conversation Definitions', () => {
       }),
     ]
     const namedToolScalar = snapshot(assembler(namedToolHistory))
-    const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
+    const namedToolInputs = packedInputs(namedToolHistory)
+    const namedToolMessage = namedToolInputs.find(input => input.event.type === 'assistant/message')?.event
+    if (namedToolMessage?.type !== 'assistant/message') throw new Error('expected packed named-tool message')
+    expect(namedToolMessage.data.stream.length).toBeGreaterThan(0)
+    const namedToolPacked = snapshot(assembler(namedToolInputs))
     expect(namedToolPacked).toEqual(namedToolScalar)
     expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
       timing: { firstTokenTime: 4_000 },

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

@@ -709,13 +709,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionAreaProps',
     declaration: 'export interface SessionAreaProps {\n    empty?: (() => ReactNode) | undefined;\n    children: ReactNode;\n}',
   },
+  {
+    name: 'SessionAssistantSettlementEntry',
+    declaration: 'export interface SessionAssistantSettlementEntry {\n    readonly type: \'event\';\n    readonly event: SessionEvent<\'assistant/message\'> | SessionEvent<\'assistant/attempt\'>;\n}',
+  },
   {
     name: 'SessionBinding',
     declaration: 'export interface SessionBinding {\n    readonly sessionId: SessionId;\n    readonly session: SessionFace;\n    readonly eventSource: SessionEventSource;\n    readonly ctx: AgentContext;\n}',
   },
   {
     name: 'SessionEventChange',
-    declaration: 'export type SessionEventChange = {\n    readonly kind: \'replace\';\n    readonly entries: readonly SessionEventLikeEntry[];\n} | {\n    readonly kind: \'prepend\';\n    readonly entries: readonly SessionEventLikeEntry[];\n} | {\n    readonly kind: \'append\';\n    readonly entries: readonly SessionEventLikeEntry[];\n};',
+    declaration: 'export type SessionEventChange = {\n    readonly kind: \'replace\';\n    readonly entries: readonly SessionEventLikeEntry[];\n} | {\n    readonly kind: \'prepend\';\n    readonly entries: readonly SessionEventLikeEntry[];\n} | {\n    readonly kind: \'append\';\n    readonly entries: readonly SessionEventLikeEntry[];\n} | {\n    readonly kind: \'settle-assistant\';\n    readonly attemptId: LlmAttemptId;\n    readonly entry?: SessionAssistantSettlementEntry;\n};',
   },
   {
     name: 'SessionEventLikeEntry',

+ 15 - 8
packages/llm/llm/src/assistant-stream.ts

@@ -1,6 +1,6 @@
 /** Lossless compact representation of one model-stream attempt. */
 
-import { deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
+import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
 import type { ToolCallId } from './brand.ts'
 import type { StreamChunk } from './types.ts'
 
@@ -113,15 +113,17 @@ export class AssistantStreamAccumulator {
       }
       case 'tool-call-delta': {
         safeIndex(chunk.index, chunk.type)
-        if (typeof chunk.id !== 'string' || chunk.id.length === 0) {
-          throw new TypeError('tool-call-delta id must be a non-empty string')
-        }
-        if (Object.hasOwn(chunk, 'name') && (typeof chunk.name !== 'string' || chunk.name.length === 0)) {
-          throw new TypeError('tool-call-delta name must be a non-empty string')
+        if (typeof chunk.id !== 'string') throw new TypeError('tool-call-delta id must be a string')
+        if (Object.hasOwn(chunk, 'name') && typeof chunk.name !== 'string') {
+          throw new TypeError('tool-call-delta name must be a string')
         }
         if (typeof chunk.argumentsDelta !== 'string') {
           throw new TypeError('tool-call-delta argumentsDelta must be a string')
         }
+        if (chunk.id.length === 0 || chunk.name === '') {
+          this.records.push({ type: 'chunk', time, chunk })
+          return timed
+        }
         const gap = previous?.type === 'tool-call-chunks' ? safeGap(previous.lastTime, time) : undefined
         const sameName = previous?.type === 'tool-call-chunks'
           && Object.hasOwn(previous, 'name') === Object.hasOwn(chunk, 'name')
@@ -148,10 +150,15 @@ export class AssistantStreamAccumulator {
         }
         return timed
       }
-      default:
+      case 'block-start':
+      case 'block-end':
+      case 'usage':
+      case 'finish':
         this.records.push({ type: 'chunk', time, chunk })
+        return timed
+      default:
+        return assertNever(chunk, 'AssistantStreamAccumulator.push')
     }
-    return timed
   }
 
   /**

+ 23 - 6
packages/llm/llm/tests/assistant-stream.spec.ts

@@ -131,7 +131,7 @@ describe('AssistantStreamAccumulator', () => {
     ])
   })
 
-  it('rejects unsafe admission before mutating the accumulator', () => {
+  it('rejects unsafe values while preserving JSON-safe empty adapter identities', () => {
     const accumulator = new AssistantStreamAccumulator()
     expect(() => accumulator.push({
       time: 0.5,
@@ -149,23 +149,40 @@ describe('AssistantStreamAccumulator', () => {
       time: 1,
       chunk: { type: 'text-delta', index: 0, text: 1 } as never,
     })).toThrow(/text must be a string/)
-    expect(() => accumulator.push({
+    expect(accumulator.push({
       time: 1,
       chunk: { type: 'tool-call-delta', index: 0, id: ToolCallId(''), argumentsDelta: '{}' },
-    })).toThrow(/id/)
-    expect(() => accumulator.push({
+    }).chunk).toMatchObject({ id: '' })
+    expect(accumulator.push({
       time: 1,
       chunk: {
         type: 'tool-call-delta', index: 0, id: ToolCallId('call'), name: '', argumentsDelta: '{}',
       },
-    })).toThrow(/name/)
+    }).chunk).toMatchObject({ name: '' })
     expect(() => accumulator.push({
       time: 1,
       chunk: {
         type: 'tool-call-delta', index: 0, id: ToolCallId('call'), argumentsDelta: 1,
       } as never,
     })).toThrow(/argumentsDelta must be a string/)
-    expect(accumulator.snapshot()).toStrictEqual([])
+    expect(() => accumulator.push({
+      time: 1,
+      chunk: { type: 'future' } as never,
+    })).toThrow(/unreachable variant in AssistantStreamAccumulator\.push/)
+    expect(accumulator.snapshot()).toStrictEqual([
+      {
+        type: 'chunk', time: 1,
+        chunk: { type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
+      },
+      {
+        type: 'chunk', time: 1,
+        chunk: { type: 'tool-call-delta', index: 0, id: 'call', name: '', argumentsDelta: '{}' },
+      },
+    ])
+    expect(expandAssistantStream(accumulator.snapshot()).map(member => member.chunk)).toStrictEqual([
+      { type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
+      { type: 'tool-call-delta', index: 0, id: 'call', name: '', argumentsDelta: '{}' },
+    ])
   })
 
   it.each([

+ 7 - 5
packages/session/session-format-catalog/tests/catalog.spec.ts

@@ -35,17 +35,19 @@ describe('first-party Session format catalog', () => {
     })
   })
 
-  it('restores exact current payloads while retaining explicitly ignorable extensions', () => {
+  it('restores the installed current vocabulary without freezing ordinary payload additions', () => {
     const header = {
       type: 'session', version: 2, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
     }
-    expect(() => sessionFormatCatalog.decodeArtifact(header, [{
+    const extended = sessionFormatCatalog.decodeArtifact(header, [{
       type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
-    }])).toThrow(/unexpected field postReleaseMember/)
+    }])
+    expect(sessionFormatCatalog.migrate(extended).events).toEqual(extended.events)
 
-    expect(() => sessionFormatCatalog.decodeArtifact(header, [{
+    const unknownRequired = sessionFormatCatalog.decodeArtifact(header, [{
       type: 'ordinary/not-installed', seq: 0, time: 1, data: 'future',
-    }])).toThrow(/unknown event type/)
+    }])
+    expect(() => sessionFormatCatalog.migrate(unknownRequired)).toThrow(/unknown event type/)
 
     const extension = sessionFormatCatalog.decodeArtifact(header, [{
       type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true,

+ 1 - 0
packages/session/session-format-v0-to-v1/src/dispositions.ts

@@ -13,6 +13,7 @@ export interface ReleasedV0PayloadDisposition {
  * @param required - members that must be present.
  * @param optional - additional admitted members.
  * @param opaque - members retained as lossless JSON without nested semantic inspection.
+ * @param extensionArms - nested discriminant paths that admit owner-defined variants.
  * @returns the detached frozen disposition.
  */
 export function defineReleasedPayloadDisposition(

+ 17 - 4
packages/session/session-format-v0-to-v1/src/relationships.ts

@@ -27,11 +27,21 @@ interface ToolLifecycle {
   state: 'advertised' | 'started'
 }
 
+/** Relationship roles added by a later format while reusing the released validator. */
+export interface ReleasedRelationshipExtensions {
+  /** Event types that must occur inside the current open step. */
+  readonly stepEvents?: ReadonlySet<string>
+}
+
 /**
  * Validate cross-event relationships required to construct one current Session safely.
  * @param artifact - complete normalized v0 or exact current v1 artifact.
+ * @param extensions - later-generation event roles interpreted by the calling format owner.
  */
-export function assertReleasedArtifactRelationships(artifact: SessionFormatArtifact): void {
+export function assertReleasedArtifactRelationships(
+  artifact: SessionFormatArtifact,
+  extensions: ReleasedRelationshipExtensions = {},
+): void {
   let openTurn: number | null = null
   let openStep: number | null = null
   let openStepProvider: string | undefined
@@ -48,14 +58,18 @@ export function assertReleasedArtifactRelationships(artifact: SessionFormatArtif
   const commandRuns = new Set<string>()
 
   for (const event of artifact.events) {
-    if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined
-      && event.type !== 'assistant/attempt') continue
+    const extensionStepEvent = extensions.stepEvents?.has(event.type) === true
+    if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined && !extensionStepEvent) continue
     const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`)
     if (SURFACE_TYPES.has(event.type)) surface = applySurface(surface, event)
     if ((event.type === 'turn/start' || event.type === 'turn/end')
       && openCompaction !== undefined && !staleCompactionStarts.has(openCompaction.startSeq)) {
       throw new SessionFormatError(`${event.type} crosses an open compaction`)
     }
+    if (extensionStepEvent) {
+      requireOpenStep(event, data, openTurn, openStep)
+      continue
+    }
 
     switch (event.type) {
       case 'turn/start':
@@ -92,7 +106,6 @@ export function assertReleasedArtifactRelationships(artifact: SessionFormatArtif
         nextStep += 1
         break
       case 'assistant/chunk':
-      case 'assistant/attempt':
         requireOpenStep(event, data, openTurn, openStep)
         break
       case 'assistant/message': {

+ 2 - 2
packages/session/session-format-v1-to-v2/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/session/session-format-v1-to-v2/README.md
-README.md: cff1ebe186e2211247501051da2020a2af673cb9
-README.zh.md: 0acb26502c211c88d72ca2579430ec1641a3ac5f
+README.md: 5ead8532b2cce52a0cbf4c39bfe9e54ef14002f1
+README.zh.md: 1f41d24007f95f799a9667910acb93d494a2aa2e

+ 5 - 5
packages/session/session-format-v1-to-v2/README.md

@@ -42,15 +42,15 @@ A successful v1 `assistant/message` must cite its complete ordered attempt. The
 
 The migration refuses a reference to a consumed chunk instead of redirecting it to a different semantic event. It remaps declared event provenance, surface replacements, command source events, compaction ranges and lists, and title message lists. A seeded source also refuses an inherited cut that splits an Assistant attempt; the target marks the exact cut with `session/end-seed { inherited: true }`.
 
-The v2 physical header requires `isSeeded` and does not store a numeric cut. The codec derives the cut from the last inherited end-seed marker, writes one event per row, and range-encodes only `sourceEventSeqs`. Strict migration-target validation rejects unknown event types, while current restoration retains installed extensions and unknown events carrying `ignorable: true`. Both paths reject unexpected members, malformed compact streams, and disagreement between a non-empty stream and its assembled message, usage, or replay state.
+The v2 physical header requires `isSeeded` and does not store a numeric cut. The codec derives the cut from the last inherited end-seed marker, writes one event per row, range-encodes only `sourceEventSeqs`, and remains neutral to ordinary event vocabulary and payload growth. Strict migration-target validation freezes the released-v2 inventory and rejects unknown types or members. Current restoration instead admits event types known to the installed Session package plus unknown events carrying `ignorable: true`, then delegates payload and stream semantics to the installed current restorer. All paths retain strict header, event-envelope, sequence, and inherited-cut validation.
 
-### Measure current-read acceptance
+### Measure catalog-dispatch overhead
 
 ```text
 pnpm run benchmark:session-format-v1-to-v2
 ```
 
-The manual acceptance runs three repetitions with 100 warmup pairs and 600 alternating measured pairs per case. It compares the current v2 catalog-dispatch read against a direct-current read of the same backend, id, file, and decoder, and requires every pooled median and p95 regression to stay within 5%. Add `--smoke` only for a short correctness and reporting pass; smoke timing is non-gating and is not an acceptance result.
+The manual acceptance runs three repetitions with 100 warmup pairs and 600 alternating measured pairs per case. It measures static catalog-routing overhead against direct released-v2 restoration of the same already parsed physical rows, requiring every pooled median and p95 regression to stay within 5%. It neither compares v1 with v2 nor times backend I/O; representation sizes and absolute migration/replay costs are reported separately without a speedup claim. Add `--smoke` only for a short correctness and reporting pass; smoke timing is non-gating and is not an acceptance result.
 
 -----
 
@@ -66,9 +66,9 @@ The edge first groups v1 chunks by turn, step, terminal finish, and explicit mes
 |---|---|
 | [`src/migration.ts`](src/migration.ts) | Attempt grouping, settlement substitution, dense sequence mapping, and reference rewriting |
 | [`src/codec.ts`](src/codec.ts) | Released-v2 header, one-event-per-row encoding, provenance ranges, and recoverable prefix decoding |
-| [`src/validation.ts`](src/validation.ts) | Exact v2 header, envelope, payload, stream, relationship, and seed-cut validation |
+| [`src/validation.ts`](src/validation.ts) | Physical v2 envelope/cut validation, exact migration-target policy, and vocabulary-neutral current restoration |
 | [`src/dispositions.ts`](src/dispositions.ts) | Frozen released-v2 event and payload-member inventory |
-| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | Repository-only current-read, migration, token-meter, size, and memory acceptance report |
+| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | Repository-only catalog-dispatch, migration, token-meter, size, and memory acceptance report |
 
 </details>
 

+ 5 - 5
packages/session/session-format-v1-to-v2/README.zh.md

@@ -42,15 +42,15 @@ const migratedV2 = sessionFormatV1ToV2.migrate(decodedV1)
 
 如果引用指向被消费的 chunk,迁移会失败,而不会把它重定向到语义不同的事件。它会重映射已声明的事件 provenance、surface replacement、command source event、compaction range 与 list,以及 title message list。带 seed 的源若让继承切点切开一个 Assistant attempt,也会迁移失败;目标会用 `session/end-seed { inherited: true }` 标出精确切点。
 
-v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,并且只对 `sourceEventSeqs` 做范围编码。严格的迁移目标校验会拒绝未知事件类型;当前恢复则保留已安装的扩展,以及携带 `ignorable: true` 的未知事件。两条路径都会拒绝意外成员、格式错误的紧凑 stream,以及非空 stream 与组装后的 message、usage 或 replay state 之间的不一致。
+v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,只对 `sourceEventSeqs` 做范围编码,并对普通事件词汇与 payload 扩展保持中立。严格的迁移目标校验会冻结 released-v2 清单并拒绝未知 type 或 member。当前恢复则准入 installed Session package 已知的事件 type,以及携带 `ignorable: true` 的未知事件,再把 payload 与 stream 语义交给 installed current restorer。所有路径仍严格校验 header、event envelope、sequence 与 inherited cut。
 
-### 测量当前读取验收
+### 测量 catalog dispatch 开销
 
 ```text
 pnpm run benchmark:session-format-v1-to-v2
 ```
 
-手工 acceptance 会运行三轮,每个 case 使用 100 组 warmup pair 与 600 组交替测量 pair。它把当前 v2 catalog-dispatch 读取与相同 backend、id、file 和 decoder 的 direct-current 读取比较,并要求每个 pooled median 与 p95 regression 都保持在 5% 以内。只在需要较短的正确性与报告检查时添加 `--smoke`;smoke timing 不参与 gate,也不是 acceptance 结果。
+手工 acceptance 会运行三轮,每个 case 使用 100 组 warmup pair 与 600 组交替测量 pair。它针对同一批已经解析的物理 row,把静态 catalog routing 与直接 released-v2 restoration 比较,并要求每个 pooled median 与 p95 regression 都保持在 5% 以内。它既不比较 v1 与 v2,也不计入 backend I/O;representation size 与 migration/replay 绝对成本会单独报告,且不声称加速。只在需要较短的正确性与报告检查时添加 `--smoke`;smoke timing 不参与 gate,也不是 acceptance 结果。
 
 -----
 
@@ -66,9 +66,9 @@ pnpm run benchmark:session-format-v1-to-v2
 |---|---|
 | [`src/migration.ts`](src/migration.ts) | Attempt 分组、settlement 替换、密集序号映射与引用重写 |
 | [`src/codec.ts`](src/codec.ts) | 已发布 v2 header、每行一个事件的编码、provenance 范围与可恢复前缀解码 |
-| [`src/validation.ts`](src/validation.ts) | 精确的 v2 header、信封、payload、stream、关系与 seed 切点校验 |
+| [`src/validation.ts`](src/validation.ts) | v2 物理 envelope/cut 校验、精确 migration-target 策略与 vocabulary-neutral current restoration |
 | [`src/dispositions.ts`](src/dispositions.ts) | 冻结的已发布 v2 事件与 payload 成员清单 |
-| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | 仅用于仓库的 current-read、migration、token-meter、size 与 memory acceptance report |
+| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | 仅用于仓库的 catalog-dispatch、migration、token-meter、size 与 memory acceptance report |
 
 </details>
 

+ 3 - 6
packages/session/session-format-v1-to-v2/src/codec.ts

@@ -13,13 +13,10 @@ import type {
   SessionFormatJsonObject,
   SessionFormatJsonValue,
 } from '@deepseek-ai/dsh-session-format'
-import { RELEASED_V2_EVENT_TYPES } from './dispositions.ts'
-import { assertReleasedV2Header, restoreReleasedV2Artifact } from './validation.ts'
+import { assertReleasedV2Header, assertReleasedV2PhysicalArtifact } from './validation.ts'
 
 const HEADER_REQUIRED = ['type', 'version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const
 const HEADER_OPTIONAL = ['cwd', 'parentSession', 'origin', 'agentPreset'] as const
-const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
-
 /** Frozen physical JSON codec for released v2. */
 export const releasedV2SessionFormatCodec = Object.freeze({
   version: 2,
@@ -110,7 +107,7 @@ function decodeArtifact(
   }
   const inheritedEventCount = deriveInheritedEventCount(header, events)
   const artifact = snapshotSessionFormatArtifact({ header, inheritedEventCount, events }, 'released v2 artifact')
-  restoreReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET)
+  assertReleasedV2PhysicalArtifact(artifact)
   return artifact
 }
 
@@ -142,7 +139,7 @@ function deriveInheritedEventCount(header: SessionFormatHeader, events: readonly
 }
 
 function encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatArtifact {
-  restoreReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET)
+  assertReleasedV2PhysicalArtifact(artifact)
   const header = artifact.header
   const physicalHeader = snapshotSessionFormatJson({
     type: 'session',

+ 2 - 0
packages/session/session-format-v1-to-v2/src/migration.ts

@@ -147,6 +147,8 @@ function collectAttemptGroups(events: readonly SessionFormatEvent[]): readonly A
       continue
     }
     if (sources.length === 0) {
+      // Released v1 uses an explicit empty list to state that this message
+      // owns no preceding chunks; an absent list cannot make that claim.
       groups.push({ turn, step, chunks: [], terminal: true, messageSeq: event.seq })
       continue
     }

+ 25 - 11
packages/session/session-format-v1-to-v2/src/validation.ts

@@ -29,6 +29,9 @@ const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result
 const SURFACE_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const
 const LOG_OPTIONAL = ['ignorable'] as const
 const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
+const RELEASED_V2_RELATIONSHIP_EXTENSIONS = {
+  stepEvents: new Set(['assistant/attempt']),
+} as const
 
 /**
  * Validate the exact logical header written by released v2.
@@ -63,13 +66,22 @@ export function assertReleasedV2Header(header: SessionFormatHeader): void {
  * @throws {SessionFormatUnsupportedMigrationError} when the artifact contains an unknown event type.
  */
 export function assertReleasedV2Artifact(artifact: SessionFormatArtifact): void {
-  validateReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET, false)
+  validateReleasedV2Artifact(artifact, 'target', RELEASED_V2_EVENT_TYPE_SET)
+}
+
+/**
+ * Validate only the released-v2 physical header, event envelopes, and inherited cut.
+ * Event vocabulary and payload semantics belong to target or installed-current restoration.
+ * @param artifact - complete physical-codec output.
+ */
+export function assertReleasedV2PhysicalArtifact(artifact: SessionFormatArtifact): void {
+  validateReleasedV2Artifact(artifact, 'physical')
 }
 
 function validateReleasedV2Artifact(
   artifact: SessionFormatArtifact,
-  knownEventTypes: ReadonlySet<string>,
-  allowIgnorableUnknown: boolean,
+  mode: 'target' | 'current' | 'physical',
+  knownEventTypes?: ReadonlySet<string>,
 ): void {
   assertReleasedV2Header(artifact.header)
   const cut = sessionFormatCount(artifact.inheritedEventCount, 'format v2 inherited event count')
@@ -81,17 +93,17 @@ function validateReleasedV2Artifact(
     const type = record['type']
     if (typeof type !== 'string') throw new SessionFormatError(`format v2 event ${index} type must be a string`)
     const disposition = RELEASED_V2_EVENT_DISPOSITIONS[type]
-    const installed = knownEventTypes.has(type)
+    const installed = knownEventTypes?.has(type) === true
     const ignorableUnknown = disposition === undefined
-      && allowIgnorableUnknown
+      && mode === 'current'
       && record['ignorable'] === true
-    if (disposition === undefined && !installed && !ignorableUnknown) {
+    if (mode !== 'physical' && disposition === undefined && !installed && !ignorableUnknown) {
       throw new SessionFormatUnsupportedMigrationError(
         `format v2 contains unknown event type ${JSON.stringify(type)} at seq ${index}`,
       )
     }
     const surface = disposition !== undefined && SURFACE_TYPES.has(type)
-    const optional = disposition === undefined
+    const optional = mode === 'physical' || disposition === undefined
       ? SURFACE_OPTIONAL
       : surface ? SURFACE_OPTIONAL : LOG_OPTIONAL
     exactKeys(record, EVENT_REQUIRED, optional, `format v2 event ${index}`)
@@ -100,8 +112,8 @@ function validateReleasedV2Artifact(
     if (record['ignorable'] !== undefined && record['ignorable'] !== true) {
       throw new SessionFormatError(`format v2 event ${index} ignorable must be true when present`)
     }
-    if (surface) assertReleasedSurfaceMetadata(record, index, type, 'forbid-assistant')
-    if (disposition !== undefined) assertPayload(event, disposition)
+    if (mode === 'target' && surface) assertReleasedSurfaceMetadata(record, index, type, 'forbid-assistant')
+    if (mode === 'target' && disposition !== undefined) assertPayload(event, disposition)
     if (type === 'session/end-seed') {
       const data = jsonRecord(event.data, `session/end-seed ${index} data`)
       if (data['inherited'] === true) lastInheritedMarker = index
@@ -113,7 +125,9 @@ function validateReleasedV2Artifact(
   if (!artifact.header.isSeeded && lastInheritedMarker !== undefined) {
     throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker')
   }
-  assertReleasedArtifactRelationships(artifact)
+  if (mode === 'target') {
+    assertReleasedArtifactRelationships(artifact, RELEASED_V2_RELATIONSHIP_EXTENSIONS)
+  }
 }
 
 function assertPayload(
@@ -201,6 +215,6 @@ export function restoreReleasedV2Artifact(
   artifact: SessionFormatArtifact,
   knownEventTypes: ReadonlySet<string>,
 ): SessionFormatArtifact {
-  validateReleasedV2Artifact(artifact, knownEventTypes, true)
+  validateReleasedV2Artifact(artifact, 'current', knownEventTypes)
   return artifact
 }

+ 6 - 4
packages/session/session-format-v1-to-v2/tests/codec.spec.ts

@@ -126,10 +126,12 @@ describe('releasedV2SessionFormatCodec rows', () => {
     expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
   })
 
-  it('round-trips an unknown ignorable event for current-build restoration', () => {
-    const source = artifact([{
-      type: 'external/ignorable', seq: 0, time: 1, data: { retained: true }, ignorable: true,
-    }])
+  it('keeps the v2 physical codec vocabulary-neutral for current growth and a future source freeze', () => {
+    const source = artifact([
+      { type: 'external/required', seq: 0, time: 1, data: { retained: true } },
+      { type: 'external/ignorable', seq: 1, time: 2, data: { retained: true }, ignorable: true },
+      { type: 'turn/start', seq: 2, time: 3, data: { turn: 1, postReleaseMember: true } },
+    ])
 
     const encoded = releasedV2SessionFormatCodec.encodeArtifact(source)
 

+ 3 - 0
packages/session/session-format-v1-to-v2/tests/validation.spec.ts

@@ -416,6 +416,9 @@ describe('released v2 restoration seam', () => {
       installedExtension,
       new Set([...RELEASED_V2_EVENT_TYPES, 'external/installed']),
     )).toBe(installedExtension)
+    const extendedPayload = artifact([event('turn/start', 0, { turn: 1, postReleaseMember: true })])
+    expect(restoreReleasedV2Artifact(extendedPayload, new Set(RELEASED_V2_EVENT_TYPES)))
+      .toBe(extendedPayload)
     expect(() => restoreReleasedV2Artifact(
       artifact([event('external/unknown', 0, {})]),
       new Set(RELEASED_V2_EVENT_TYPES),

+ 2 - 2
packages/session/session-log-deepseek/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/session/session-log-deepseek/README.md
-README.md: eea3cc5abe2b02d20a5aba9ad9b33dbc2a290196
-README.zh.md: 96b5256a050db26f5cf90b927ef9f847a13cbf14
+README.md: 29e6b7860b2654f4ff272c2e9d23d3490527e396
+README.zh.md: ec104bf51c7d9e4589fa19f3fbba2ecc762b1889

+ 1 - 1
packages/session/session-log-deepseek/README.md

@@ -34,7 +34,7 @@ Shipped profiles mount the plugin so an overlay can enable it, but the default c
 <a id="request-field"></a>
 ## Request field
 
-For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session format generation, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-1 field contains `sessionFormatVersion`, a raw session header with the `isSeeded` lineage bit, numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because both the recorded Session id and format generation must match the request source.
+For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session format generation, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-2 field contains `sessionFormatVersion`, a raw session header with the `isSeeded` lineage bit, numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because both the recorded Session id and format generation must match the request source.
 
 <a id="acceptance-and-retry"></a>
 ## Acceptance and retry

+ 1 - 1
packages/session/session-log-deepseek/README.zh.md

@@ -34,7 +34,7 @@ kind: "package-reference"
 <a id="request-field"></a>
 ## 请求字段
 
-对于携带存活 `sessionId` 的请求,插件会折叠该确切会话格式代的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 1 字段包含 `sessionFormatVersion`、带 `isSeeded` 谱系位的原始会话 header、数值型 `afterSeq` 与 `throughSeq`,以及把完整权威事件翻译为原始数值 envelope 字段后的数组元素。只有记录的会话 id 与格式代均匹配请求来源时水位才生效,因此 fork 会话会忽略从父会话继承的水位。
+对于携带存活 `sessionId` 的请求,插件会折叠该确切会话格式代的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 2 字段包含 `sessionFormatVersion`、带 `isSeeded` 谱系位的原始会话 header、数值型 `afterSeq` 与 `throughSeq`,以及把完整权威事件翻译为原始数值 envelope 字段后的数组元素。只有记录的会话 id 与格式代均匹配请求来源时水位才生效,因此 fork 会话会忽略从父会话继承的水位。
 
 <a id="acceptance-and-retry"></a>
 ## 接受与重试

+ 1 - 1
packages/session/session-log-deepseek/src/index.ts

@@ -147,7 +147,7 @@ export function apply(ctx: Context, config: Config): void {
       if (throughSeq === undefined) return undefined
       const suffix = session.snapshotEvents(SessionLogOffset(afterSeq + 1))
       const value: DeepSeekSessionLogExtension = {
-        version: 1,
+        version: 2,
         sessionFormatVersion: session.header.version,
         session: wireHeader(session),
         afterSeq: Number(afterSeq),

+ 1 - 1
packages/session/session-log-deepseek/src/types.ts

@@ -35,7 +35,7 @@ export interface DeepSeekSessionLogWireEvent {
 
 /** Versioned incremental session-log field carried by an official DeepSeek request. */
 export interface DeepSeekSessionLogExtension {
-  readonly version: 1
+  readonly version: 2
   /** Session format generation represented by this suffix. */
   readonly sessionFormatVersion: number
   readonly session: DeepSeekSessionLogWireHeader

+ 1 - 0
packages/session/session-log-deepseek/tests/upload.spec.ts

@@ -82,6 +82,7 @@ describe('incremental DeepSeek session-log upload', () => {
     const first = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: session.id })
     const firstPayload = first.fields.dsh_session_log
     expect(firstPayload).toMatchObject({
+      version: 2,
       sessionFormatVersion: SESSION_FORMAT_VERSION,
       afterSeq: -1,
       throughSeq: 1,

+ 1 - 0
packages/session/session-persistence-jsonl/package.json

@@ -38,6 +38,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   }

+ 79 - 0
packages/session/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -19,6 +19,7 @@ import {
 import { runLiveWritePathContract } from '../../session-persistence/tests/live-write-contract.ts'
 import { LIVE_WRITE_BATCH_MAX_DELAY_MS, type JsonlSessionHandle } from '../src/storage.ts'
 import SessionStore from '@deepseek-ai/dsh-session'
+import { releasedV1SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v0-to-v1'
 
 const statRace = vi.hoisted(() => ({
   path: undefined as string | undefined,
@@ -130,6 +131,40 @@ function releasedV0Header(header: SessionHeader): Record<string, unknown> {
   }
 }
 
+function releasedV1PackedPhysicalLog(header: SessionHeader): string {
+  const source = releasedV1OneTurnLog()
+  const extraChunks: SessionEvent[] = [
+    {
+      type: 'assistant/chunk', seq: SessionSeq(5), time: 3,
+      data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } },
+    } as unknown as SessionEvent,
+    {
+      type: 'assistant/chunk', seq: SessionSeq(6), time: 3,
+      data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } },
+    } as unknown as SessionEvent,
+  ]
+  const events = [
+    ...source.slice(0, 5),
+    ...extraChunks,
+    ...source.slice(5).map((event): SessionEvent => ({
+      ...event,
+      seq: SessionSeq(event.seq + 2),
+      ...(event.type === 'assistant/message'
+        ? { sourceEventSeqs: [3, 4, 5, 6, 7, 8].map(SessionSeq) }
+        : {}),
+    })),
+  ]
+  const encoded = releasedV1SessionFormatCodec.encodeArtifact({
+    header: { ...header, version: 1, delegationDepth: header.delegationDepth ?? 0 },
+    inheritedEventCount: 0,
+    events,
+  } as never, { packChunks: true })
+  if (!encoded.rows.some(row => row['type'] === 'text-chunks')) {
+    throw new Error('released v1 test fixture did not produce a packed text row')
+  }
+  return [encoded.header, ...encoded.rows].map(row => JSON.stringify(row)).join('\n') + '\n'
+}
+
 /** Create + append + close: persist one whole log through the write handle. */
 async function writeLog(persistence: SessionPersistence, m: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
   const handle = await persistence.create(m)
@@ -588,6 +623,50 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
       .toEqual(['session.jsonl', 'session.v2.jsonl'])
   })
 
+  it('publishes v2 beside an unchanged physical v1 source with packed chunk rows', async () => {
+    const header = meta('released-v1-read', '/work')
+    const sourcePath = generationLogPath(root, header.cwd, header.id, 1, 'none')
+    const currentPath = rawLogPath(root, header.cwd, header.id)
+    const source = Buffer.from(releasedV1PackedPhysicalLog(header))
+    await mkdir(dirname(sourcePath), { recursive: true })
+    await writeFile(sourcePath, source)
+
+    const restored = await readAll(ctx.sessionPersistence, header.id)
+    expect(restored.meta).toEqual({ ...header, delegationDepth: 0 })
+    expect(restored.events.map(event => event.type)).toEqual([
+      'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end',
+    ])
+    expect(restored.events.find(event => event.type === 'assistant/message'))
+      .toMatchObject({ data: { message: { content: [{ type: 'text', text: 'hello' }] } } })
+
+    expect(await readFile(sourcePath)).toEqual(source)
+    expect(JSON.parse((await readFile(currentPath, 'utf8')).split('\n')[0] as string))
+      .toMatchObject({ id: header.id, version: SESSION_FORMAT_VERSION })
+    expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
+      .toEqual(['session.v1.jsonl', 'session.v2.jsonl'])
+  })
+
+  it('selects v1 from a v0/v1 directory, then v2 from the retained three-generation set', async () => {
+    const header = meta('mixed-generation-read', '/work')
+    const directory = sessionDir(root, header.cwd, header.id)
+    const v0Path = historicalLogPath(root, header.cwd, header.id)
+    const v1Path = generationLogPath(root, header.cwd, header.id, 1, 'none')
+    const v2Path = rawLogPath(root, header.cwd, header.id)
+    await mkdir(directory, { recursive: true })
+    await writeFile(v0Path, `${JSON.stringify(releasedV0Header(header))}\n`)
+    await writeFile(v1Path, releasedV1PackedPhysicalLog(header))
+
+    const migrated = await readAll(ctx.sessionPersistence, header.id)
+    expect(migrated.events.map(event => event.type)).toContain('assistant/message')
+    expect((await readdir(directory)).filter(name => name.startsWith('session')).sort())
+      .toEqual(['session.jsonl', 'session.v1.jsonl', 'session.v2.jsonl'])
+
+    await writeFile(v0Path, 'corrupt lower v0\n')
+    await writeFile(v1Path, 'corrupt lower v1\n')
+    await expect(readAll(ctx.sessionPersistence, header.id)).resolves.toEqual(migrated)
+    expect(await readFile(v2Path, 'utf8')).toContain('"version":2')
+  })
+
   it('uses the same migration path for a handle storage resolution', async () => {
     const header = meta('released-v0-handle-read', '/work')
     const sourcePath = historicalLogPath(root, header.cwd, header.id)

+ 5 - 60
packages/test-support/session-snapshot/src/normalize.ts

@@ -454,64 +454,10 @@ export function normalizeSessionSnapshots(
  * @returns the same records without delivery or captured-source generation qualifiers.
  */
 export function normalizeSessionFormatProvenance(rawLog: string): string {
-  const lines = rawLog.split('\n')
-  const records = lines.map(line => line.trim().length === 0
-    ? undefined
-    : JSON.parse(line) as Record<string, unknown>)
-  const inertEndSeeds = records.flatMap((record) => {
-    if (record?.type !== 'session/end-seed' || record.data === null
-      || typeof record.data !== 'object' || Array.isArray(record.data)
-      || (record.data as Record<string, unknown>).inherited === true) return []
-    return typeof record.seq === 'number' ? [record.seq] : []
-  }).sort((left, right) => left - right)
-  const remapSeq = (value: unknown): unknown => {
-    if (typeof value !== 'number' || !Number.isSafeInteger(value)) return value
-    if (inertEndSeeds.includes(value)) {
-      throw new Error(`Session snapshot comparison reference targets inert end-seed ${String(value)}`)
-    }
-    return value - inertEndSeeds.filter(seq => seq < value).length
-  }
-  const remapList = (value: unknown): unknown => Array.isArray(value) ? value.map(remapSeq) : value
-
-  return records.flatMap((record, index) => {
-    if (record === undefined) return [lines[index] as string]
-    if (record.type === 'session/end-seed' && record.data !== null
-      && typeof record.data === 'object' && !Array.isArray(record.data)
-      && (record.data as Record<string, unknown>).inherited !== true) return []
+  return rawLog.split('\n').map((line) => {
+    if (line.trim().length === 0) return line
+    const record = JSON.parse(line) as Record<string, unknown>
     let changed = normalizeCapturedFormatProvenance(record)
-    const recordSeq = record.seq
-    if (typeof recordSeq === 'number' && inertEndSeeds.some(seq => seq < recordSeq)) {
-      record.seq = remapSeq(recordSeq)
-      changed = true
-    }
-    if (Array.isArray(record.sourceEventSeqs)) {
-      record.sourceEventSeqs = remapList(record.sourceEventSeqs)
-      changed = true
-    }
-    if (record.surfaceOp !== null && typeof record.surfaceOp === 'object' && !Array.isArray(record.surfaceOp)) {
-      const operation = record.surfaceOp as Record<string, unknown>
-      record.surfaceOp = { ...operation, start: remapSeq(operation.start), end: remapSeq(operation.end) }
-      changed = true
-    }
-    if (record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data)) {
-      const data = record.data as Record<string, unknown>
-      if (Object.hasOwn(data, 'sourceEventSeq')) {
-        record.data = { ...data, sourceEventSeq: remapSeq(data.sourceEventSeq) }
-        changed = true
-      } else if (Array.isArray(data.messageSeqs)) {
-        record.data = { ...data, messageSeqs: remapList(data.messageSeqs) }
-        changed = true
-      } else if (data.shadowedRange !== null && typeof data.shadowedRange === 'object'
-        && !Array.isArray(data.shadowedRange)) {
-        const range = data.shadowedRange as Record<string, unknown>
-        record.data = {
-          ...data,
-          shadowedRange: { ...range, start: remapSeq(range.start), end: remapSeq(range.end) },
-          shadowedSeqs: remapList(data.shadowedSeqs),
-        }
-        changed = true
-      }
-    }
     if (record.type === 'session' && Object.hasOwn(record, 'version')) {
       delete record.version
       changed = true
@@ -519,14 +465,13 @@ export function normalizeSessionFormatProvenance(rawLog: string): string {
     if (record.type === 'session-log-deepseek/delivery-accepted'
       && record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data)) {
       const data = { ...record.data as Record<string, unknown> }
-      if (Object.hasOwn(data, 'sessionFormatVersion') || Object.hasOwn(data, 'throughSeq')) {
+      if (Object.hasOwn(data, 'sessionFormatVersion')) {
         delete data.sessionFormatVersion
-        delete data.throughSeq
         record.data = data
         changed = true
       }
     }
-    return [changed ? JSON.stringify(record) : lines[index] as string]
+    return changed ? JSON.stringify(record) : line
   }).join('\n')
 }
 

+ 1 - 84
packages/test-support/session-snapshot/tests/normalize.spec.ts

@@ -748,7 +748,7 @@ describe('normalizeSessionSnapshot', () => {
       .split('\n')
       .map(line => JSON.parse(line) as Record<string, unknown>) ?? []
 
-    expect(delivery?.data).toEqual({ otherVersion: 8 })
+    expect(delivery?.data).toEqual({ throughSeq: 21, otherVersion: 8 })
     expect(captured?.data).toMatchObject({
       source: {
         references: [
@@ -792,89 +792,6 @@ describe('normalizeSessionSnapshot', () => {
     expect(normalized[1]).toBe(lookalike.split('\n')[1])
   })
 
-  it('removes an inert end-seed and densely remaps declared references for comparison', () => {
-    const raw = [
-      { type: 'session', version: 2, id: 's', createdAt: 0, isSeeded: false, delegationDepth: 0 },
-      { type: 'feedback/record', seq: 0, time: 0, data: { text: 'before' } },
-      { type: 'session/end-seed', seq: 1, time: 0, data: {} },
-      { type: 'session/title', seq: 2, time: 0, data: { title: 'title', messageSeqs: [0, 2] } },
-      { type: 'command/done', seq: 3, time: 0, data: { sourceEventSeq: 2 } },
-      {
-        type: 'user/message',
-        seq: 4,
-        time: 0,
-        data: {},
-        sourceEventSeqs: [0, 2],
-        surfaceOp: { op: 'replace', start: 0, end: 2 },
-      },
-    ].map(record => JSON.stringify(record)).join('\n')
-
-    const records = normalizeSessionFormatProvenance(raw).split('\n')
-      .map(line => JSON.parse(line) as Record<string, unknown>)
-
-    expect(records.map(record => record.type)).not.toContain('session/end-seed')
-    expect(records[2]).toMatchObject({ seq: 1, data: { messageSeqs: [0, 1] } })
-    expect(records[3]).toMatchObject({ seq: 2, data: { sourceEventSeq: 1 } })
-    expect(records[4]).toMatchObject({
-      seq: 3,
-      sourceEventSeqs: [0, 1],
-      surfaceOp: { start: 0, end: 1 },
-    })
-  })
-
-  it('remaps multiple inert markers, compaction provenance, and opaque reference values', () => {
-    const raw = [
-      { type: 'session', version: 2 },
-      { type: 'session/end-seed', seq: 1, data: {} },
-      { type: 'session/end-seed', seq: 3, data: {} },
-      {
-        type: 'compaction/end',
-        seq: 5,
-        data: {
-          shadowedRange: { start: 0, end: 5 },
-          shadowedSeqs: 'opaque',
-        },
-      },
-      {
-        type: 'session-log-deepseek/delivery-accepted',
-        seq: 6,
-        data: { retained: true },
-      },
-      {
-        type: 'custom/event',
-        seq: 7,
-        sourceEventSeqs: ['opaque', 1.5, 5],
-        ignorable: true,
-      },
-      { type: 'session/end-seed', data: {} },
-    ].map(record => JSON.stringify(record)).join('\n')
-
-    const records = normalizeSessionFormatProvenance(raw).split('\n')
-      .map(line => JSON.parse(line) as Record<string, unknown>)
-
-    expect(records).toHaveLength(4)
-    expect(records[1]).toMatchObject({
-      seq: 3,
-      data: {
-        shadowedRange: { start: 0, end: 3 },
-        shadowedSeqs: 'opaque',
-      },
-    })
-    expect(records[2]).toMatchObject({ seq: 4, data: { retained: true } })
-    expect(records[3]).toMatchObject({ seq: 5, sourceEventSeqs: ['opaque', 1.5, 3] })
-  })
-
-  it('rejects a declared reference to an inert end-seed', () => {
-    const raw = [
-      { type: 'session', version: 2 },
-      { type: 'session/end-seed', seq: 1, data: {} },
-      { type: 'command/done', seq: 2, data: { sourceEventSeq: 1 } },
-    ].map(record => JSON.stringify(record)).join('\n')
-
-    expect(() => normalizeSessionFormatProvenance(raw))
-      .toThrow('Session snapshot comparison reference targets inert end-seed 1')
-  })
-
   it('projects persisted provenance ranges back to logical seq arrays', () => {
     const raw = [
       JSON.stringify({ type: 'session', version: 0 }),

+ 6 - 0
pnpm-lock.yaml

@@ -1688,6 +1688,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-api-session-controller':
+        specifier: workspace:^
+        version: link:../../api/session-controller
       '@deepseek-ai/dsh-attachment':
         specifier: workspace:^
         version: link:../../attachment/attachment
@@ -7182,6 +7185,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-format-v0-to-v1':
+        specifier: workspace:^
+        version: link:../session-format-v0-to-v1
       '@deepseek-ai/dsh-session-persistence':
         specifier: workspace:^
         version: link:../session-persistence

+ 32 - 1
python/sdk/tests/test_smoke_model.py

@@ -164,7 +164,38 @@ def test_snapshot_comparison_expands_embedded_assistant_streams() -> None:
         "surfaceOp": "append",
     }]
 
-    assert normalize(actual) == normalize(expected)
+    assert normalize(actual, 2) == normalize(expected, 1)
+
+    tool_result = {
+        "type": "tool/result",
+        "data": {"turn": 1, "step": 1},
+        "sourceEventSeqs": [4],
+    }
+    assert normalize(tool_result, 1)["sourceEventSeqs"] == [4]
+    assert normalize(tool_result, 2)["sourceEventSeqs"] == [4]
+
+
+def test_snapshot_stream_expands_reasoning_and_tool_call_records() -> None:
+    expand = SMOKE["expand_snapshot_stream_member"]
+
+    assert expand({
+        "type": "reasoning-chunks", "time0": 0, "index": 1,
+        "dt": [], "texts": ["think"],
+    }) == [{"type": "reasoning-delta", "index": 1, "text": "think"}]
+    assert expand({
+        "type": "tool-call-chunks", "time0": 0, "index": 2,
+        "id": "call-1", "name": "read", "dt": [1], "args": ["{", "}"],
+    }) == [
+        {"type": "tool-call-delta", "index": 2, "id": "call-1", "name": "read", "argumentsDelta": "{"},
+        {"type": "tool-call-delta", "index": 2, "id": "call-1", "name": "read", "argumentsDelta": "}"},
+    ]
+
+
+def test_snapshot_file_builder_order_is_checked_outside_update_mode(tmp_path: Path) -> None:
+    compare = SMOKE["compare_snapshot_files"]
+
+    with pytest.raises(AssertionError, match="snapshot builder produced"):
+        compare({}, False, tmp_path, ("result.json",))
 
 
 def test_snapshot_comparison_expands_sdk_wrapped_attempts() -> None:

+ 5 - 5
scripts/benchmark-session-format-v1-to-v2.ts

@@ -183,7 +183,7 @@ export function parseOptions(argv: readonly string[]): BenchmarkOptions {
   return { ...values, smoke, help }
 }
 
-/** Calculate the same discrete percentile used by the PR3 acceptance measurement. */
+/** Calculate the discrete percentile used by the acceptance measurement. */
 export function percentile(values: readonly number[], fraction: number): number {
   if (values.length === 0) throw new Error('percentile requires at least one sample')
   if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1) {
@@ -210,7 +210,7 @@ async function main(): Promise<void> {
     const cases = await materializeCurrentReadCases(root, fixtures)
     await validateFixtureReads(cases)
 
-    console.log('Session format v2 performance acceptance')
+    console.log('Session format v2 catalog-dispatch overhead acceptance')
     console.log(
       'Direct-current/no-dispatch baseline: the released-v2 codec restores the same parsed physical rows. '
       + 'The candidate routes those rows through the static format catalog; public handles validate each file once.',
@@ -226,7 +226,7 @@ async function main(): Promise<void> {
     console.log('')
 
     let accepted = true
-    console.log('Current v2 physical decode and restoration (pooled hot samples)')
+    console.log('Released-v2 restoration dispatch overhead (pooled hot samples)')
     for (const benchmarkCase of cases) {
       const result = runPairedCurrentRead(benchmarkCase, options)
       accepted &&= result.passed
@@ -263,10 +263,10 @@ async function main(): Promise<void> {
       console.log('SMOKE COMPLETE (non-acceptance timing sample)')
     } else if (accepted) {
       console.log('')
-      console.log('PASS: every pooled current-read median and p95 regression is within the 5% ceiling.')
+      console.log('PASS: every pooled catalog-dispatch median and p95 regression is within the 5% ceiling.')
     } else {
       console.error('')
-      console.error('FAIL: at least one pooled current-read median or p95 regression exceeds the 5% ceiling.')
+      console.error('FAIL: at least one pooled catalog-dispatch median or p95 regression exceeds the 5% ceiling.')
       process.exitCode = 1
     }
   } finally {

+ 0 - 1
scripts/client-bundle-purity.spec.ts

@@ -95,7 +95,6 @@ describe('client bundle purity gate', () => {
     expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
     expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
     expect(resolveId('@deepseek-ai/dsh-deque')).toBeNull()
-    expect(resolveId('@deepseek-ai/dsh-timeout')).toBeNull()
     expect(resolveId('@deepseek-ai/dsh-util-values')).toBeNull()
     expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
     expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)

+ 19 - 11
scripts/smoke-python-runtime.py

@@ -114,7 +114,7 @@ ADVANCED_SNAPSHOT_DIRECTORY = (
     Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
 )
 ADVANCED_SNAPSHOT_FILENAMES = (
-    "result.json", "session.v1.jsonl", "session.1.v1.jsonl", "session.2.v1.jsonl",
+    "result.json", "session.v2.jsonl", "session.1.v2.jsonl", "session.2.v2.jsonl",
 )
 MINIMAL_SNAPSHOT_DIRECTORY = (
     Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal"
@@ -126,7 +126,7 @@ RESTART_SNAPSHOT_DIRECTORY = (
     Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart"
 )
 RESTART_SNAPSHOT_FILENAMES = (
-    "result.json", "requests.json", "session.1.v1.jsonl", "session.2.v1.jsonl",
+    "result.json", "requests.json", "session.1.v2.jsonl", "session.2.v2.jsonl",
 )
 MCP_SERVER_SCRIPT = """\
 import json
@@ -1888,7 +1888,7 @@ def expand_snapshot_stream_member(member: object) -> list[dict[str, object]]:
         return [chunk]
     packed_kinds = {
         "text-chunks": ("texts", "text-delta", "text"),
-        "reasoning-chunks": ("reasoning", "reasoning-delta", "reasoning"),
+        "reasoning-chunks": ("texts", "reasoning-delta", "text"),
         "tool-call-chunks": ("args", "tool-call-delta", "argumentsDelta"),
     }
     packed = packed_kinds.get(member_type)
@@ -1958,11 +1958,14 @@ def expand_snapshot_assistant_event(value: object) -> list[object]:
     return expanded
 
 
-def normalize_session_format_comparison(value: object) -> object:
+def normalize_session_format_comparison(
+    value: object,
+    source_session_version: int | None = None,
+) -> object:
     """Canonicalize only generation provenance that differs across immutable Session files."""
     if isinstance(value, list):
         return [
-            normalize_session_format_comparison(expanded)
+            normalize_session_format_comparison(expanded, source_session_version)
             for item in value
             for expanded in expand_snapshot_assistant_event(item)
         ]
@@ -1970,7 +1973,7 @@ def normalize_session_format_comparison(value: object) -> object:
         return value
 
     normalized = {
-        key: normalize_session_format_comparison(item)
+        key: normalize_session_format_comparison(item, source_session_version)
         for key, item in value.items()
     }
     if normalized.get("type") == "session" and "version" in normalized:
@@ -1988,6 +1991,7 @@ def normalize_session_format_comparison(value: object) -> object:
     if isinstance(normalized.get("type"), str) and "data" in normalized:
         normalized.pop("seq", None)
         normalized.pop("time", None)
+    if source_session_version == 1 and normalized.get("type") == "assistant/message":
         normalized.pop("sourceEventSeqs", None)
     if normalized.get("type") == "session-log-deepseek/delivery-accepted":
         data = normalized.get("data")
@@ -2008,11 +2012,15 @@ def normalize_session_format_comparison(value: object) -> object:
 def normalize_snapshot_comparison_text(name: str, content: str) -> str:
     """Normalize Session generation provenance only while comparing committed expected outputs."""
     if name.startswith("session") and name.endswith(".jsonl"):
+        parsed = [json.loads(line) for line in content.splitlines() if line]
+        header = parsed[0] if parsed else None
+        source_version = header.get("version") if isinstance(header, dict) else None
+        if not isinstance(source_version, int):
+            raise AssertionError(f"{name}: snapshot Session header has no integer format version")
         records = [
-            normalize_session_format_comparison(expanded)
-            for line in content.splitlines()
-            if line
-            for expanded in expand_snapshot_assistant_event(json.loads(line))
+            normalize_session_format_comparison(expanded, source_version)
+            for record in parsed
+            for expanded in expand_snapshot_assistant_event(record)
         ]
         return render_jsonl(records)
     if name.endswith(".json"):
@@ -2032,7 +2040,7 @@ def compare_snapshot_files(
 ) -> None:
     """Write or exactly compare one scenario's expected snapshot files."""
     scenario = directory.name
-    if update and tuple(files) != filenames:
+    if tuple(files) != filenames:
         raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}")
     if update:
         directory.mkdir(parents=True, exist_ok=True)

ファイルの差分が大きいため隠しています
+ 0 - 1
snapshots/sdk/subagent-continuable-inheritance/session.1.jsonl


+ 3 - 4
snapshots/sdk/subagent-continuable/session.1.jsonl

@@ -1,6 +1,5 @@
 {"type":"session","version":0,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","origin":"subagent","delegationDepth":1}
 {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
-{"type":"session/end-seed","data":{}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
 {"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
 {"type":"permission/preset","data":{"preset":"danger-full-access"}}
@@ -12,7 +11,7 @@
 {"type":"step/start","data":{"turn":1,"step":1}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[11],"source":{"kind":"fallback"}}}
+{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[10],"source":{"kind":"fallback"}}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -20,7 +19,7 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":2,"inserted":[]}}
 {"type":"step/start","data":{"turn":1,"step":2}}
@@ -31,6 +30,6 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 3 - 3
snapshots/sdk/subagent-dsh-sdk-diagnostic/session.jsonl

@@ -22,7 +22,7 @@
 {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_foreground"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)\nPartial output before the run ended:\npartial child loader answer"}],"isError":true}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
-{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":22}}
+{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":17}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_background","name":"subagent_dsh_sdk_background","argumentsDelta":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_background","name":"subagent_dsh_sdk_background","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}}
@@ -33,7 +33,7 @@
 {"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_background"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[30],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"step/start","data":{"turn":1,"step":3}}
-{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":33}}
+{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":23}}
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
@@ -44,7 +44,7 @@
 {"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_output"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)]"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":3}}
 {"type":"step/start","data":{"turn":1,"step":4}}
-{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":44}}
+{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":29}}
 {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}}

+ 2 - 3
snapshots/sdk/subagent-list-agents/session.1.jsonl

@@ -1,6 +1,5 @@
 {"type":"session","version":0,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","origin":"subagent","delegationDepth":1}
 {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
-{"type":"session/end-seed","data":{}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
 {"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
 {"type":"permission/preset","data":{"preset":"danger-full-access"}}
@@ -10,7 +9,7 @@
 {"type":"step/start","data":{"turn":1,"step":1}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[9],"source":{"kind":"fallback"}}}
+{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -18,6 +17,6 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 5
snapshots/sdk/subagent-send-message/session.1.jsonl

@@ -1,6 +1,5 @@
 {"type":"session","version":0,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","origin":"subagent","delegationDepth":1}
 {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Send a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
-{"type":"session/end-seed","data":{}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
 {"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
 {"type":"permission/preset","data":{"preset":"danger-full-access"}}
@@ -10,7 +9,7 @@
 {"type":"step/start","data":{"turn":1,"step":1}}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Send exactly CHILD_MESSAGE_OK to your parent with send_message, then stop."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:11}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"}
-{"type":"session/title","data":{"title":"Send exactly CHILD_MESSAGE_OK to your","messageSeqs":[9],"source":{"kind":"fallback"}}}
+{"type":"session/title","data":{"title":"Send exactly CHILD_MESSAGE_OK to your","messageSeqs":[8],"source":{"kind":"fallback"}}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -18,9 +17,9 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
 {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}}
-{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_send_message_1"},"content":[{"type":"tool-result","toolCallId":"call_send_message_1","content":[{"type":"text","text":"message delivered to agent {{session:1}}"}],"isError":false}],"role":"user","id":"{{message:14}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"}
+{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_send_message_1"},"content":[{"type":"tool-result","toolCallId":"call_send_message_1","content":[{"type":"text","text":"message delivered to agent {{session:1}}"}],"isError":false}],"role":"user","id":"{{message:14}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -28,6 +27,6 @@
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Message sent."}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
 {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Message sent."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Message sent."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":2}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

+ 50 - 13
snapshots/session/headless.snapshot.ts

@@ -340,26 +340,19 @@ function stderrFromSession(log: string): string {
     open = false
     endsWithNewline = true
   }
-  for (const record of records(log)) {
-    if (record.type === 'turn/start') {
-      close()
-      started = true
-      continue
-    }
-    if (!started) continue
-    const data = record.data as JsonObject | undefined
-    if (record.type === 'reasoning-chunks') {
+  const consume = (type: unknown, data: JsonObject | undefined): void => {
+    if (type === 'reasoning-chunks') {
       if (!Array.isArray(data?.texts) || data.texts.some(text => typeof text !== 'string')) {
         throw new Error('headless snapshot reasoning chunks have invalid text')
       }
       for (const text of data.texts as string[]) appendReasoning(text)
-      continue
+      return
     }
-    if (record.type === 'text-chunks' || record.type === 'tool-call-chunks') {
+    if (type === 'text-chunks' || type === 'tool-call-chunks') {
       close()
-      continue
+      return
     }
-    if (record.type !== 'assistant/chunk') continue
+    if (type !== 'assistant/chunk' && type !== 'chunk') return
     const chunk = data?.chunk as JsonObject | undefined
     switch (chunk?.type) {
       case 'reasoning-delta':
@@ -383,6 +376,28 @@ function stderrFromSession(log: string): string {
         break
     }
   }
+  for (const record of records(log)) {
+    if (record.type === 'turn/start') {
+      close()
+      started = true
+      continue
+    }
+    if (!started) continue
+    const data = record.data as JsonObject | undefined
+    if ((record.type === 'assistant/message' || record.type === 'assistant/attempt')
+      && Array.isArray(data?.stream)) {
+      for (const entry of data.stream) {
+        if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
+          throw new Error('headless snapshot embedded stream has an invalid entry')
+        }
+        const streamRecord = entry as JsonObject
+        consume(streamRecord.type, streamRecord)
+      }
+      close()
+      continue
+    }
+    consume(record.type, data)
+  }
   close()
   const reason = turnReasonFromSession(log)
   if (reason?.kind !== 'error') return output
@@ -674,6 +689,28 @@ describe('headless recorded-session snapshots', () => {
     ].join('\n'))
   })
 
+  it('reconstructs reasoning stderr from embedded message and attempt streams', () => {
+    const log = [
+      { type: 'turn/start', data: { turn: 1 } },
+      {
+        type: 'assistant/attempt',
+        data: { stream: [{ type: 'reasoning-chunks', texts: ['first', ' thought'] }] },
+      },
+      {
+        type: 'assistant/message',
+        data: {
+          stream: [
+            { type: 'chunk', chunk: { type: 'reasoning-delta', index: 0, text: 'second' } },
+            { type: 'chunk', chunk: { type: 'finish', reason: { kind: 'stop' } } },
+          ],
+        },
+      },
+      { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
+    ].map(record => JSON.stringify(record)).join('\n')
+
+    expect(stderrFromSession(log)).toBe('dsh: reasoning:\nfirst thought\ndsh: reasoning:\nsecond\n')
+  })
+
   for (const scenario of scenarios) {
     const skipped = scenario.manifest.platform === 'posix' && process.platform === 'win32'
       || scenario.manifest.platform === 'pwsh' && !hasPwsh

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません