Bläddra i källkod

Merge remote-tracking branch 'origin/release/session-log-v3' into integrate/v3-master-refresh-345248

Tianyi Cui 3 dagar sedan
förälder
incheckning
6982a8f53c

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: d92b1c34dee293d53ffb8ed5bd77958eba34eaad
-2026-08-31-released-session-format-migrations.zh.md: b2e6fb8e2c3018ef3fdca9cd5ac2204f705dbc90
+2026-08-31-released-session-format-migrations.md: eb5eb14f28a6459bd388caa2ea106ec01d1ebbe6
+2026-08-31-released-session-format-migrations.zh.md: b06bfac059541e9c397ff46fe7d169690c5b1213

+ 4 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -70,6 +70,8 @@ The [V2-to-V3 delivery guards](../../../../packages/session/session-format-v2-to
 
 The [V2-to-V3 README](../../../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is the single specification for that edge's transformations, preservation, and refusal; its separate [native admission section](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) prevents current-only capabilities from being mistaken for historical transformations. The released V2 codec remains owned by V1→V2 and is reused, not copied. The [system-prompt](2026-09-02-system-prompt-as-surface-node.md), [PTC](../feature/2026-06-15-ptc.md), and [canonical-envelope](2026-09-06-v3-canonical-session-envelopes.md) notes retain their independent rationale, not duplicate conversion specifications. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
 
+Historical content admission belongs to the incoming edge, not native V3 extension validation. Preserving an unknown block without understanding its fields cannot establish that migration preserves its meaning. The [source audit](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) therefore uses one historical kind set across its explicitly owned content positions, including partial streams. It inspects admitted content without rewriting it and leaves owner-opaque JSON uninterpreted. Narrowing native acceptance or editing frozen predecessor validators would change independent promises rather than establish safe conversion.
+
 Preset renames cover the creation header and every selection event because the latest selection controls resume while earlier selections control historical forks. Rewriting only the last selection loses that distinction. The released `code` id denotes the legacy built-in preset; migration is independent of the installed roster so the same bytes produce the same result on every host. Native V3 custom ids remain available without a global runtime alias.
 
 A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
@@ -122,6 +124,8 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
 
 The migration specification requires evidence for transformations, preservation, and refusal separately. Direct-edge and native V3 tests cannot establish seeded multi-hop publication: preceding assistant-stream folding changes source coordinates before V3 inserts system events. Tests through the real catalog and JSONL provider therefore need raw and compressed V0/V1 inputs, mapped references and inherited cuts, publish/reopen equivalence, unchanged predecessor bytes, and no intermediate generations. Coverage percentages alone cannot prove those cross-stage relationships; combined assertions must compare the resulting history and refusal effects.
 
+Content-admission evidence must cover every position named in the specification, nested results, partial starts, and malformed known blocks, with source-coordinate diagnostics. Successful migration must preserve admitted content and opaque values. Refusal through real persistence must leave the source unchanged and publish no successor. Native V3 tests must independently retain extension acceptance under both catalog validation policies; historical refusal is not evidence of native rejection.
+
 ### Benchmark input and meanings
 
 The benchmark uses Node v24.18.0 and one 116,228,655-byte v0 Zstandard log containing 317,540 frames and 454,151 physical rows. The old reader restores 9,143,111 expanded v0 events. Migration produces 72,784 current v2 events with artifact SHA-256 `fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c`.

+ 4 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -70,6 +70,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
 
 [V2 到 V3 README](../../../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是该迁移边转换、保留与拒绝规则的单一规范真源;单列的[原生准入章节](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)避免将仅当前版本支持的能力误认为历史转换。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。[系统提示词](2026-09-02-system-prompt-as-surface-node.zh.md)、[PTC](../feature/2026-06-15-ptc.zh.md)和[规范信封](2026-09-06-v3-canonical-session-envelopes.zh.md)记录保留各自独立依据,而非重复转换规范。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
 
+历史内容准入归入边所有,而非原生 V3 扩展校验。在不了解字段的情况下保留未知块,不能证明迁移保留了其含义。因此,[源审计](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)在明确归其所有的内容位置(包括未完成的流)使用同一历史种类集合。它检查已接纳的内容而不改写,并且不解释归其他所有者所有的不透明 JSON。收紧原生准入或修改冻结的前代校验器,会改变独立承诺,而非证明转换安全。
+
 预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 `code` 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。
 
 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
@@ -122,6 +124,8 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o
 
 迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。
 
+内容准入证据必须覆盖规范列出的每个位置、嵌套结果、未完成的起始记录和已知种类的畸形块,并验证诊断使用源坐标。成功迁移必须保留已接纳的内容与不透明值。经真实持久化路径拒绝时,必须保持源不变且不发布后继代。原生 V3 测试必须独立证明两种目录校验策略均保留扩展准入;历史拒绝不能证明原生输入也被拒绝。
+
 ### Benchmark 输入与口径
 
 Benchmark 使用 Node v24.18.0 和一份 116,228,655-byte 的 v0 Zstandard 日志,其中包含 317,540 个 frame 与 454,151 个 physical row。老 reader 会恢复 9,143,111 个展开后的 v0 event;migration 会生成 72,784 个 current v2 event,artifact SHA-256 为 `fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c`。

+ 48 - 0
apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts

@@ -139,6 +139,54 @@ describe('session format guard through the assembled app', () => {
     expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/')))
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
+  it('refuses unaudited V2 queued content before resume without publishing a successor', async () => {
+    let sourcePath = ''
+    let source = Buffer.alloc(0)
+    let sourceIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined
+    const result = await runLoaderSmoke({
+      label: 'unaudited V2 content resume refusal',
+      tempDirPrefix: 'dsh-format-guard-content-',
+      binScript,
+      libBinScript: binScript,
+      configPath,
+      binArgs: [configPath, 'Try to resume.'],
+      tsconfigPath,
+      env: { DSH_SNAPSHOT_FILE: replayFixture },
+      expectedExitCode: 1,
+      prepare: async (runCwd) => {
+        sourcePath = generationLogPath(join(runCwd, '.sessions'), runCwd, sessionId, 2, 'none')
+        await mkdir(dirname(sourcePath), { recursive: true })
+        const rows = [
+          { type: 'session', version: 2, id: sessionId, createdAt: 1, cwd: runCwd, isSeeded: false, delegationDepth: 0 },
+          { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+          { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+          { type: 'team/message/queued', seq: 2, time: 3, data: {
+            version: 1, teamId: 'team', message: {
+              id: 'queued', senderId: 'sender', senderName: 'Sender', targetId: 'target', delivery: 'quiet',
+              content: [{ type: 'future-message-block', localSeq: 1 }],
+            },
+          } },
+        ]
+        source = Buffer.from(rows.map(row => JSON.stringify(row)).join('\n') + '\n')
+        await writeFile(sourcePath, source)
+        const identity = await stat(sourcePath, { bigint: true })
+        sourceIdentity = { dev: identity.dev, ino: identity.ino }
+      },
+      inspect: async () => {
+        expect(await readFile(sourcePath)).toEqual(source)
+        const identity = await stat(sourcePath, { bigint: true })
+        expect({ dev: identity.dev, ino: identity.ino }).toEqual(sourceIdentity)
+        expect((await readdir(dirname(sourcePath))).filter(name => name !== 'session.lock'))
+          .toEqual(['session.v2.jsonl'])
+      },
+    })
+    expect(result.stderr).toContain(
+      'format v2 team/message/queued at seq 2 data.message.content[0]: cannot safely transform unclassified message content kind "future-message-block"',
+    )
+    expect(result.stderr).toContain('source v2 artifact remains unchanged')
+    expect(result.stderr).toContain(sourcePath.slice(sourcePath.indexOf('/.sessions/')))
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
   it('refuses to resume a log with an unknown required event type', async () => {
     let sessionPath = ''
     const result = await runLoaderSmoke({

+ 2 - 2
packages/session/session-format-v2-to-v3/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-v2-to-v3/README.md
-README.md: 2aa143bb7872acb1541eb209cbc1cb6a9fcfd1eb
-README.zh.md: d0839198ca6dce91eeda2a4e17ce90fdcd33a33a
+README.md: c9ea46c14bce7e84c23af2a06e0cd310f13397cb
+README.zh.md: 64306531b7fcf2c32815e3bbc3c73cb52b6449cf

+ 16 - 3
packages/session/session-format-v2-to-v3/README.md

@@ -117,7 +117,21 @@ A V2 `session-log-deepseek/delivery-accepted` with `data.sessionFormatVersion ==
 <a id="source-audit"></a>
 ### Source audit and refusal
 
-Migration classifies the [released V2 event inventory](../session-format-v1-to-v2/src/dispositions.ts), including log-only `assistant/attempt`, plus `feedback/message-put` and `feedback/message-delete`. The [payload validator](src/payload.ts) applies exact admitted envelope and payload members, released nested validation, and explicit message-source/content classification. Message-source and recursive content-kind classification applies to `user/message.data`, `assistant/message.data.message`, `tool/result.data.message`, `agent/inbox/spliced.data.inserted[]`, and `session/title-llm-request.data.messages[]`. In these slots, unknown source/content kinds are refused; agent relay attribution and file attachment metadata are admitted without interpreting ids or byte counts as Session references. Unknown events, even ignorable ones, and unaudited members at checked records are refused. Other captured payloads, including queued team-message content, compaction summary/raw output, and PTC dispatch content, use released checks without this additional recursive classification or coordinate inference. This is not a general schema audit of every nested payload.
+Migration classifies the [released V2 event inventory](../session-format-v1-to-v2/src/dispositions.ts), including log-only `assistant/attempt`, plus `feedback/message-put` and `feedback/message-delete`. The [payload validator](src/payload.ts) applies exact admitted envelope and payload members and released nested validation. Unknown events, even ignorable ones, and unaudited members at checked records are refused. Message-source classification covers the five Message slots below: unknown source kinds are refused, while agent relay attribution is admitted without interpreting ids as Session references.
+
+The content audit admits exactly `text`, `reasoning`, `image`, `file`, `tool-call`, and `tool-result`. It validates owned block fields and recursively audits every nested `tool-result.content` in this finite set of positions:
+
+| Owner | Audited content |
+|---|---|
+| Five Message slots | `user/message.data.content`; `assistant/message.data.message.content`; `tool/result.data.message.content`; `agent/inbox/spliced.data.inserted[].content`; `session/title-llm-request.data.messages[].content` |
+| Queued team message | `team/message/queued.data.message.content`; the historical Team payload remains `version: 1` with `message.delivery` |
+| Compaction output | `compaction/summary.data.summary` and optional `compaction/summary.data.rawOutput` |
+| PTC predecessor output | `tool/code-dispatch.data.content` |
+| Embedded assistant streams | In `assistant/message.data.stream[]` and `assistant/attempt.data.stream[]`, raw `type: 'chunk'` records: `chunk.block` for `block-end` and `chunk.blockType` for `block-start`, including starts with no completed block |
+
+All positions use the same historical kind set; a partial start cannot introduce an unknown kind. Unknown kinds and malformed owned blocks refuse the whole migration; catalog restoration reports `SessionFormatUnsupportedMigrationError`. The diagnostic identifies the source event type, source sequence, full indexed payload path, and violated rule. Unknown-kind errors name the offending kind; malformed known-block errors name the kind and field error. A malformed content container or missing block reports its location without inventing a kind. Persistence leaves source bytes unchanged and publishes no successor on refusal.
+
+Admission does not rewrite content. In particular, embedded stream bytes are preserved although their owned block fields are inspected. Tool arguments, `replayState.response`, and `replayState.blocks` remain opaque; matching field names inside arbitrary JSON do not trigger this audit. File attachment metadata is validated without interpreting ids or byte counts as Session references. This is not a general schema audit or recursive coordinate inference, and native V3 extension acceptance is separate.
 
 A surface event before the first step, a changed prompt outside an open step, or a generated-id collision raises `SessionFormatUnsupportedMigrationError` rather than moving events or inventing ownership. Malformed source fields, missing placement, invalid references, inconsistent cuts, delivery violations, and contradictory tool results raise format errors in the direct stage or target validator. The catalog reports migration-stage and transformed-target validation failures as typed unsupported migration; physical decoding failures remain corruption under its selected recovery policy. No source or target repair, generation fallback, or file rewrite is performed by this edge.
 
@@ -180,8 +194,7 @@ The edge preserves historical request meaning and model configuration; it does n
 <a id="known-limitations-and-deferred-work"></a>
 
 - **Historical preset ambiguity** — released `code` references cannot distinguish a custom preset with the legacy built-in id; the [exact rename](#header-and-presets) is host-independent.
-- **Bounded source audit** — [source refusal](#source-audit) can reject history that cannot be converted without changing chronology, but nested content outside the classified Message slots does not receive the same kind audit. Native extension support does not imply migration support.
-- **No file or settings migration** — this package never changes committed generations or `settings.yaml`. Persistence owns publishing the final successor; an existing V3 generation does not rerun its incoming edge.
+- **No file or settings migration** — this package never changes committed generations or `settings.yaml`. Persistence owns publishing the final successor; an existing V3 generation does not rerun its incoming edge. V3 is unreleased; compatibility or repair for already-written development V3 files is not provided.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 16 - 3
packages/session/session-format-v2-to-v3/README.zh.md

@@ -117,7 +117,21 @@ V2 `session-log-deepseek/delivery-accepted` 若携带 `data.sessionFormatVersion
 <a id="source-audit"></a>
 ### 源审计与拒绝
 
-迁移分类[已发布 V2 事件清单](../session-format-v1-to-v2/src/dispositions.ts),包括仅日志的 `assistant/attempt`,以及 `feedback/message-put` 和 `feedback/message-delete`。[载荷校验器](src/payload.ts)应用精确的已接纳信封和载荷成员、已发布嵌套校验,以及显式消息来源/内容分类。消息来源与递归内容种类分类适用于 `user/message.data`、`assistant/message.data.message`、`tool/result.data.message`、`agent/inbox/spliced.data.inserted[]` 和 `session/title-llm-request.data.messages[]`。这些位置的未知来源/内容种类会被拒绝;agent(智能体)中继归属和文件附件元数据被接纳,但标识与字节计数不会被解释为 Session 引用。未知事件(即使可忽略)以及被检查记录中未经审计的成员均被拒绝。其他捕获载荷(包括排队的团队消息内容、压缩摘要/原始输出和 PTC 分发内容)使用已发布检查,不执行这层额外的递归分类或坐标推断。这不是对每个嵌套载荷的通用 schema 审计。
+迁移分类[已发布 V2 事件清单](../session-format-v1-to-v2/src/dispositions.ts),包括仅日志的 `assistant/attempt`,以及 `feedback/message-put` 和 `feedback/message-delete`。[载荷校验器](src/payload.ts)应用精确的已接纳信封和载荷成员,以及已发布嵌套校验。未知事件(即使可忽略)以及被检查记录中未经审计的成员均被拒绝。消息来源分类覆盖下表的五个 Message 位置:未知来源种类会被拒绝,agent(智能体)中继归属则被接纳,但标识不会被解释为 Session 引用。
+
+内容审计仅接纳 `text`、`reasoning`、`image`、`file`、`tool-call` 和 `tool-result`。它校验归本格式所有的块字段,并在以下有限位置递归审计每层嵌套的 `tool-result.content`:
+
+| 所有者 | 审计内容 |
+|---|---|
+| 五个 Message 位置 | `user/message.data.content`;`assistant/message.data.message.content`;`tool/result.data.message.content`;`agent/inbox/spliced.data.inserted[].content`;`session/title-llm-request.data.messages[].content` |
+| 排队的团队消息 | `team/message/queued.data.message.content`;历史 Team 载荷保持 `version: 1` 并带有 `message.delivery` |
+| 压缩输出 | `compaction/summary.data.summary` 和可选的 `compaction/summary.data.rawOutput` |
+| PTC 前代输出 | `tool/code-dispatch.data.content` |
+| 内嵌 assistant 流 | `assistant/message.data.stream[]` 和 `assistant/attempt.data.stream[]` 中的原始 `type: 'chunk'` 记录:`block-end` 的 `chunk.block` 和 `block-start` 的 `chunk.blockType`,包括尚无完整块的起始记录 |
+
+所有位置共用同一历史种类集合;未完成的起始记录不能引入未知种类。未知种类和归本格式所有的畸形块都会拒绝整次迁移;目录恢复报告 `SessionFormatUnsupportedMigrationError`。诊断标明源事件类型、源序号、包含索引的完整载荷路径和违反的规则。未知种类错误标明违规种类;已知块的畸形错误标明种类和字段错误。畸形内容容器或缺失块报告其位置,而不虚构种类。拒绝时,持久化保留源字节且不发布后继代。
+
+准入不改写内容。特别是,内嵌流虽然接受归本格式所有的块字段检查,其字节仍保持不变。工具参数、`replayState.response` 和 `replayState.blocks` 保持不透明;任意 JSON 内的同名字段不会触发此审计。文件附件元数据接受校验,但标识或字节计数不会被解释为 Session 引用。这不是通用 schema 审计或递归坐标推断,原生 V3 扩展准入与此分开。
 
 首个步骤前的 surface 事件、开放步骤外的提示词变化或生成标识冲突,会抛出 `SessionFormatUnsupportedMigrationError`,而非移动事件或虚构归属。源字段格式错误、缺失位置、无效引用、不一致切点、投递违规与矛盾工具结果,会在直接阶段或目标校验器中抛出格式错误。目录将迁移阶段和转换后目标校验失败报告为类型化的不支持迁移;物理解码失败仍按所选恢复策略归类为损坏。本迁移边不修复源或目标,不回退代次,也不改写文件。
 
@@ -180,8 +194,7 @@ V2 `session-log-deepseek/delivery-accepted` 若携带 `data.sessionFormatVersion
 <a id="known-limitations-and-deferred-work"></a>
 
 - **历史预设歧义** — 已发布 `code` 引用无法区分与旧内置标识同名的自定义预设;[精确重命名](#header-and-presets)不依赖宿主。
-- **有范围的源审计** — [源拒绝](#source-audit)可能拒绝无法在保留时序的同时转换的历史,但已分类 Message 位置之外的嵌套内容不接受相同的种类审计。原生扩展支持不意味着迁移支持。
-- **不迁移文件或设置** — 本包绝不修改已提交代或 `settings.yaml`。持久化负责发布最终后继代;已有 V3 代不重新运行其入边。
+- **不迁移文件或设置** — 本包绝不修改已提交代或 `settings.yaml`。持久化负责发布最终后继代;已有 V3 代不重新运行其入边。V3 尚未发布;本包不为已写出的开发期 V3 文件提供兼容或修复。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 82 - 27
packages/session/session-format-v2-to-v3/src/payload.ts

@@ -66,10 +66,10 @@ export function assertEvent(event: SessionFormatEvent, version: 2 | 3): void {
   // Non-inventory feedback events have returned above.
   const admitted = disposition as NonNullable<typeof disposition>
   keys(data, admitted.required, admitted.optional, event.type + ' data')
+  assertOwnedContent(event, data)
   // Assistant attempts are introduced by V2; the V0 helper has no case for them.
   if (event.type !== 'assistant/attempt') assertReleasedPayloadSemantics(event, version)
   if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
-    if (!Array.isArray(data['stream'])) throw new SessionFormatError('assistant stream must be an array')
     for (const coordinate of ['turn', 'step']) {
       if (sessionFormatCount(data[coordinate], coordinate) === 0) throw new SessionFormatError(coordinate + ' must be positive')
     }
@@ -77,8 +77,7 @@ export function assertEvent(event: SessionFormatEvent, version: 2 | 3): void {
   if (event.type === 'session/end-seed' && data['inherited'] !== undefined && data['inherited'] !== true) {
     throw new SessionFormatError('session/end-seed inherited must be true')
   }
-  // These are the only payload positions containing Harness messages. Tool JSON and stream
-  // records are owner-opaque; their counters and serialized text are not local Session refs.
+  // Source classification applies only to Harness messages, not team delivery envelopes.
   if (event.type === 'user/message') assertSource(data)
   if (event.type === 'assistant/message' || event.type === 'tool/result') assertSource(record(data['message'], 'message'))
   if (event.type === 'tool/result' && isSessionFormatJsonObject(data['error']) && data['error']['code'] === 'TOOL_NOT_STARTED') {
@@ -119,34 +118,90 @@ function assertSource(message: SessionFormatJsonObject): void {
       throw new SessionFormatError('agent-message source requires relay form and senderSessionId')
     }
   }
-  assertContentKinds(message['content'])
 }
 
-function assertContentKinds(content: SessionFormatJsonValue | undefined): void {
-  // The frozen payload validator already checks content arrays, including nested tool results.
-  for (const value of content as readonly SessionFormatJsonValue[]) {
-    const block = record(value, 'message content')
-    switch (block['type']) {
-      case 'text':
-      case 'reasoning':
-      case 'image':
-      case 'tool-call':
-        break
-      case 'file': {
-        keys(block, ['type', 'attachment'], [], 'file content')
-        const attachment = record(block['attachment'], 'file attachment')
-        keys(attachment, ['attachmentId', 'name', 'bytes'], [], 'file attachment')
-        if (typeof attachment['attachmentId'] !== 'string' || attachment['attachmentId'].length === 0
-          || typeof attachment['name'] !== 'string') throw new SessionFormatError('file attachment requires attachmentId and name')
-        sessionFormatCount(attachment['bytes'], 'file attachment bytes')
-        break
+const CONTENT_KINDS = new Set(['text', 'reasoning', 'image', 'file', 'tool-call', 'tool-result'])
+
+function contentArray(value: SessionFormatJsonValue | undefined, label: string): readonly SessionFormatJsonValue[] {
+  if (!Array.isArray(value)) throw new SessionFormatError(label + ': content must be an array')
+  return value as readonly SessionFormatJsonValue[]
+}
+
+function assertOwnedContent(event: SessionFormatEvent, data: SessionFormatJsonObject): void {
+  const label = 'format v2 ' + event.type + ' at seq ' + String(event.seq) + ' data'
+  switch (event.type) {
+    case 'user/message':
+    case 'tool/code-dispatch':
+      assertContentKinds(data['content'], label + '.content')
+      break
+    case 'assistant/message':
+    case 'tool/result':
+    case 'team/message/queued':
+      assertContentKinds(record(data['message'], label + '.message')['content'], label + '.message.content')
+      break
+    case 'agent/inbox/spliced':
+    case 'session/title-llm-request': {
+      const field = event.type === 'agent/inbox/spliced' ? 'inserted' : 'messages'
+      for (const [index, value] of contentArray(data[field], label + '.' + field).entries()) {
+        const path = label + '.' + field + '[' + String(index) + ']'
+        assertContentKinds(record(value, path)['content'], path + '.content')
       }
-      case 'tool-result':
-        assertContentKinds(block['content'])
-        break
-      default:
-        throw new SessionFormatUnsupportedMigrationError('cannot safely transform unclassified message content')
+      break
     }
+    case 'compaction/summary':
+      assertContentKinds(data['summary'], label + '.summary')
+      if (data['rawOutput'] !== undefined) assertContentKinds(data['rawOutput'], label + '.rawOutput')
+      break
+  }
+  if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
+    for (const [index, value] of contentArray(data['stream'], label + '.stream').entries()) {
+      const path = label + '.stream[' + String(index) + ']'
+      const entry = record(value, path)
+      // Only raw chunks carry blocks; packed deltas and other chunk payloads remain opaque.
+      if (entry['type'] !== 'chunk') continue
+      const chunk = record(entry['chunk'], path + '.chunk')
+      if (chunk['type'] === 'block-end') assertContentBlock(chunk['block'], path + '.chunk.block')
+      if (chunk['type'] === 'block-start') assertContentKind(chunk['blockType'], path + '.chunk.blockType')
+    }
+  }
+}
+
+function assertContentKind(kind: SessionFormatJsonValue | undefined, label: string): void {
+  if (typeof kind !== 'string' || !CONTENT_KINDS.has(kind)) {
+    throw new SessionFormatUnsupportedMigrationError(label + ': cannot safely transform unclassified message content kind ' + JSON.stringify(kind))
+  }
+}
+
+function assertContentKinds(content: SessionFormatJsonValue | undefined, label: string): void {
+  for (const [index, value] of contentArray(content, label).entries()) assertContentBlock(value, label + '[' + String(index) + ']')
+}
+
+function assertContentBlock(value: SessionFormatJsonValue | undefined, label: string): void {
+  const block = record(value, label)
+  assertContentKind(block['type'], label)
+  if (block['type'] === 'tool-result') {
+    if (!Array.isArray(block['content'])) throw new SessionFormatError(label + '.content: invalid message content kind "tool-result": content must be an array')
+    assertContentKinds(block['content'], label + '.content')
+  }
+  if (block['type'] === 'file') {
+    keys(block, ['type', 'attachment'], [], label + ' kind "file"')
+    const attachment = record(block['attachment'], label + ' kind "file" attachment')
+    keys(attachment, ['attachmentId', 'name', 'bytes'], [], label + ' kind "file" attachment')
+    if (typeof attachment['attachmentId'] !== 'string' || attachment['attachmentId'].length === 0
+      || typeof attachment['name'] !== 'string') throw new SessionFormatError(label + ' kind "file": file attachment requires attachmentId and name')
+    sessionFormatCount(attachment['bytes'], label + ' kind "file" attachment bytes')
+    return
+  }
+  // Reuse frozen field rules without recursively revisiting tool-result children or interpreting opaque JSON.
+  const leaf = block['type'] === 'tool-result' ? { ...block, content: [] } : block
+  const probe: SessionFormatEvent = { type: 'user/message', seq: 0, time: 0, data: {
+    id: 'content-admission', role: 'user', source: { kind: 'user' }, content: [leaf],
+  } }
+  try {
+    assertReleasedPayloadSemantics(probe, 2)
+  } catch (error) {
+    // Frozen payload failures need the original event and content path, not the synthetic message.
+    throw new SessionFormatError(label + ': invalid message content kind ' + JSON.stringify(block['type']) + ': ' + String(error))
   }
 }
 

+ 224 - 0
packages/session/session-format-v2-to-v3/tests/content-admission.spec.ts

@@ -0,0 +1,224 @@
+import { describe, expect, it } from 'vitest'
+import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
+import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
+import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
+import { restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts'
+
+const header = { version: 2, id: 'content-admission', createdAt: 1, isSeeded: false, delegationDepth: 0 }
+const text = { type: 'text', text: 'input' }
+const future = { type: 'future-content', seq: 987, content: [{ type: 'text', text: 'opaque' }] }
+const user = { id: 'user', role: 'user', source: { kind: 'user' }, content: [text] }
+const model = { kind: 'model', provider: 'mock', model: 'mock' }
+const dispatch = { rootCallId: 'root', parentCallId: 'root', subCallId: 'sub', name: 'read', arguments: {} }
+function event(type: string, data: SessionFormatJsonObject, surface = false): SessionFormatEvent {
+  return { type, seq: 0, time: 1, data, ...(surface ? { surfaceOp: 'append' } : {}) }
+}
+const opening = [event('turn/start', { turn: 1 }), event('step/start', { turn: 1, step: 1 }), event('user/message', user, true)]
+const closing = [event('step/end', { turn: 1, step: 1 }), event('turn/end', { turn: 1, reason: { kind: 'completed' } })]
+function migrate(rows: readonly SessionFormatEvent[]) {
+  const targetHeader = sessionFormatV2ToV3.migrateHeader(header)
+  const stage = sessionFormatV2ToV3.createStage({ sourceHeader: header, targetHeader, sourceInheritedEventCount: 0, sourceKind: 'decoded' })
+  const collector = new SessionFormatEventCollector()
+  for (const [seq, row] of rows.entries()) stage.transformEvent({ ...row, seq }, collector)
+  return { header: targetHeader, inheritedEventCount: stage.finish(collector), events: collector.values }
+}
+function nested(content: SessionFormatJsonValue): SessionFormatJsonObject & { type: 'tool-result' } {
+  return { type: 'tool-result', toolCallId: 'nested', content }
+}
+function assistant(type: string, stream: SessionFormatJsonValue, content: SessionFormatJsonValue = [text]): SessionFormatEvent {
+  return event(type, { turn: 1, step: 1, stream, ...(type === 'assistant/message' ? { message: { id: 'assistant', role: 'assistant', source: model, content } } : {}) }, type === 'assistant/message')
+}
+const messageCarriers = [
+  { type: 'user/message', path: 'data.content', seq: 3, rows: (content: SessionFormatJsonValue) => [event('user/message', { ...user, id: 'second', content }, true)] },
+  { type: 'assistant/message', path: 'data.message.content', seq: 3, rows: (content: SessionFormatJsonValue) => [event('assistant/message', { turn: 1, step: 1, stream: [], message: { id: 'assistant', role: 'assistant', source: model, content } }, true)] },
+  { type: 'tool/result', path: 'data.message.content[0].content', seq: 5, rows: (content: SessionFormatJsonValue) => [
+    event('assistant/message', { turn: 1, step: 1, stream: [], message: { id: 'assistant', role: 'assistant', source: model, content: [{ type: 'tool-call', id: 'call', name: 'read', arguments: '{}' }] } }, true),
+    event('tool/call', { turn: 1, step: 1, callId: 'call', name: 'read', arguments: '{}' }),
+    event('tool/result', { turn: 1, step: 1, message: { id: 'result', role: 'user', source: { kind: 'tool', callId: 'call' }, content: [{ type: 'tool-result', toolCallId: 'call', content }] } }, true),
+  ] },
+  { type: 'agent/inbox/spliced', path: 'data.inserted[0].content', seq: 3, rows: (content: SessionFormatJsonValue) => [event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [{ ...user, id: 'inbox', content }] })] },
+  { type: 'session/title-llm-request', path: 'data.messages[0].content', seq: 3, rows: (content: SessionFormatJsonValue) => [event('session/title-llm-request', { titleProvider: 'mock', messageSeqs: [2], route: { provider: 'mock', model: 'mock' }, system: 'title', maxTokens: 987, messages: [{ ...user, id: 'title', source: { kind: 'plugin', plugin: 'dsh-session-title-llm' }, content }] })] },
+]
+const carriers = [
+  ...messageCarriers,
+  { type: 'team/message/queued', path: 'data.message.content', seq: 3, rows: (content: SessionFormatJsonValue) => [
+    event('team/message/queued', { version: 1, teamId: 'team', message: { id: 'queued', senderId: 'lead', senderName: 'lead', targetId: 'worker', delivery: 'quiet', content } }),
+  ] },
+  ...['summary', 'rawOutput'].map(field => ({ type: 'compaction/summary', path: 'data.' + field, seq: 4, rows: (content: SessionFormatJsonValue) => [
+    event('compaction/start', { compactionId: 'compact', turn: 1 }),
+    event('compaction/summary', { compactionId: 'compact', provider: 'mock', model: 'mock', summary: [text], [field]: content, shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2], shadowedTokenCount: 987 }),
+    event('compaction/end', { compactionId: 'compact', turn: 1 }),
+  ] })),
+  { type: 'tool/code-dispatch', path: 'data.content', seq: 4, rows: (content: SessionFormatJsonValue) => [
+    event('tool/code-dispatch-start', dispatch), event('tool/code-dispatch', { ...dispatch, content, isError: false }),
+  ] },
+]
+
+const admitted = [
+  text,
+  { type: 'reasoning', text: '' },
+  { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 987, width: 1, height: 2, name: '', originalDimensions: { width: 3, height: 4 } } },
+  { type: 'file', attachment: { attachmentId: 'file', name: '', bytes: 987 } },
+  { type: 'tool-call', id: 'opaque-call', name: 'read', arguments: '{"type":"future-content","seq":987}' },
+  { ...nested([text]), isError: false },
+]
+const malformed = [
+  { block: { type: 'text', text: 12 }, kind: 'text' },
+  { block: { type: 'reasoning', text: 'ok', extra: true }, kind: 'reasoning' },
+  { block: { type: 'image', attachment: {} }, kind: 'image' },
+  { block: { type: 'file', attachment: { attachmentId: 'file', name: 'file', bytes: -1 } }, kind: 'file' },
+  { block: { type: 'tool-call', id: '', name: 'read', arguments: '{}' }, kind: 'tool-call' },
+  { block: { type: 'tool-result', toolCallId: '', content: [] }, kind: 'tool-result' },
+  { block: { type: 'tool-result', toolCallId: 'call', content: null }, kind: 'tool-result' },
+] satisfies { block: SessionFormatJsonObject; kind: string }[]
+
+function catalog(rows: readonly SessionFormatEvent[], version: 0 | 1 | 3, validation: 'current' | 'transformed' = 'current') {
+  const physical = version === 3 ? { type: 'session', ...header, version } : { type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0 }
+  const reader = sessionFormatCatalog.createRestore(physical, { recovery: 'strict', validation })
+  for (const [seq, row] of rows.entries()) reader.decodeRow({ ...row, seq })
+  return reader.finish()
+}
+
+describe('V2 content admission', () => {
+  it('preserves opaque JSON, serialized arguments, compact runs and non-content counters exactly', () => {
+    const opaque = { ...future, blockType: 'future-content', block: future, blocks: [future], summary: [future], message: { content: [future] } }
+    const replayState = { response: opaque, blocks: [opaque, opaque] }
+    const stream = [
+      { type: 'text-chunks', time0: 1, index: 0, dt: [0], texts: [JSON.stringify(opaque), ''] },
+      { type: 'tool-call-chunks', time0: 1, index: 1, id: 'opaque-call', name: 'read', dt: [], args: [JSON.stringify(opaque)] },
+      { type: 'chunk', time: 1, chunk: { type: 'finish', reason: { kind: 'tool-calls' }, replayState } },
+    ]
+    const nestedDispatch = { ...dispatch, arguments: opaque }
+    const input = [...opening,
+      event('tool/code-dispatch-start', nestedDispatch), event('tool/code-dispatch', { ...nestedDispatch, content: [text], isError: false }),
+      event('assistant/message', { turn: 1, step: 1, stream, message: { id: 'assistant', role: 'assistant', source: { ...model, replayState }, content: [{ type: 'text', text: JSON.stringify(opaque) }, { type: 'tool-call', id: 'opaque-call', name: 'read', arguments: JSON.stringify(opaque) }] } }, true),
+      event('tool/call', { turn: 1, step: 1, callId: 'opaque-call', name: 'read', arguments: JSON.stringify(opaque) }),
+      event('tool/result', { turn: 1, step: 1, message: { id: 'result', role: 'user', source: { kind: 'tool', callId: 'opaque-call' }, content: [{ type: 'tool-result', toolCallId: 'opaque-call', content: [text] }] } }, true),
+      ...closing]
+    const before = JSON.stringify(input)
+    const output = migrate(input)
+    expect(restoreReleasedV3Artifact(output, new Set())).toBe(output)
+    expect(output.events.filter(row => ['tool/ptc-dispatch-start', 'tool/ptc-dispatch', 'assistant/message'].includes(row.type)).map(row => row.data)).toEqual(input.slice(3, 6).map(row => row.data))
+    expect(JSON.stringify(input)).toBe(before)
+  })
+
+  it.each([0, 1] as const)('routes historical V%s queued content through V2 admission', (version) => {
+    const good = carriers.find(carrier => carrier.type === 'team/message/queued')!.rows([text])
+    expect(catalog([...opening, ...good, ...closing], version).header.version).toBe(3)
+    const bad = carriers.find(carrier => carrier.type === 'team/message/queued')!.rows([nested([future])])
+    expect(() => catalog([...opening, ...bad, ...closing], version)).toThrow('format v2 team/message/queued at seq 3 data.message.content[0].content[0]')
+  })
+
+  it.each([0, 1] as const)('routes historical V%s chunk starts and ends through V2 admission', (version) => {
+    for (const chunk of [{ type: 'block-start', index: 987, blockType: 'text' }, { type: 'block-end', index: 987, block: text }]) {
+      expect(catalog([...opening, event('assistant/chunk', { turn: 1, step: 1, chunk })], version).header.version).toBe(3)
+    }
+    for (const chunk of [{ type: 'block-start', index: 987, blockType: 'future-content' }, { type: 'block-end', index: 987, block: nested([future]) }]) {
+      expect(() => catalog([...opening, event('assistant/chunk', { turn: 1, step: 1, chunk })], version)).toThrow('format v2 assistant/attempt at seq 3 data.stream[0].chunk')
+    }
+  })
+
+  it.each(['current', 'transformed'] as const)('keeps native V3 extension acceptance in content and raw stream records (%s)', (validation) => {
+    const base = migrate([...opening, ...closing])
+    const body = base.events.slice(0, -2)
+    const extensionRows = [
+      event('user/message', { ...user, id: 'extension', content: [future, nested([future])] }, true),
+      ...carriers.find(carrier => carrier.type === 'team/message/queued')!.rows([future]),
+      assistant('assistant/message', [{ type: 'chunk', time: 987, chunk: { type: 'block-end', index: 987, block: future } }]),
+      assistant('assistant/attempt', [{ type: 'chunk', time: 987, chunk: { type: 'block-start', index: 987, blockType: 'future-content' } }]),
+    ]
+    const output = catalog([...body, ...extensionRows, ...closing], 3, validation)
+    expect(output.events.slice(body.length, -2).map(row => row.data)).toEqual(extensionRows.map(row => row.data))
+  })
+
+  it.each(carriers.filter(carrier => carrier.type !== 'session/title-llm-request'))('accepts empty $type $path arrays', (carrier) => {
+    const output = migrate([...opening, ...carrier.rows([]), ...closing])
+    expect(restoreReleasedV3Artifact(output, new Set())).toBe(output)
+  })
+
+  for (const carrier of carriers) {
+    it('accepts valid historical ' + carrier.path + ' in ' + carrier.type, () => {
+      const output = migrate([...opening, ...carrier.rows([text]), ...closing])
+      expect(restoreReleasedV3Artifact(output, new Set())).toBe(output)
+    })
+    it('preserves valid content without mutating ' + carrier.type + ' ' + carrier.path, () => {
+      // Title request relationships require a single text block; assistant tool advertisements require results.
+      const content = carrier.type === 'session/title-llm-request' ? [text] : [nested(admitted), ...admitted.filter(block => block.type !== 'tool-call')]
+      const input = [...opening, ...carrier.rows(content), ...closing]
+      const before = JSON.stringify(input)
+      const output = migrate(input)
+      expect(restoreReleasedV3Artifact(output, new Set())).toBe(output)
+      expect(JSON.stringify(input)).toBe(before)
+      const migrated = output.events.find(row => row.type === (carrier.type === 'tool/code-dispatch' ? 'tool/ptc-dispatch' : carrier.type) && row.seq === carrier.seq + 1)!
+      if (carrier.type === 'compaction/summary') {
+        expect(migrated.data).toEqual({
+          ...input[carrier.seq]!.data as SessionFormatJsonObject, shadowedRange: { start: 3, end: 3 }, shadowedSeqs: [3],
+        })
+      } else if (carrier.type === 'session/title-llm-request') {
+        expect(migrated.data).toEqual({ ...input[carrier.seq]!.data as SessionFormatJsonObject, messageSeqs: [3] })
+      } else expect(migrated.data).toEqual(input[carrier.seq]!.data)
+    })
+    it.each(malformed)('rejects malformed $kind in ' + carrier.type + ' ' + carrier.path, ({ block, kind }) => {
+      const input = [...opening, ...carrier.rows([nested([block])]), ...closing]
+      const before = JSON.stringify(input)
+      expect(() => migrate(input)).toThrow('format v2 ' + carrier.type + ' at seq ' + String(carrier.seq) + ' ' + carrier.path + '[0].content[0]')
+      expect(() => migrate(input)).toThrow('kind "' + kind + '"')
+      expect(JSON.stringify(input)).toBe(before)
+    })
+    it.each([null, false, {}, [null], [nested(null)]])('rejects malformed content arrays in ' + carrier.type + ' %j', (content) => {
+      expect(() => migrate([...opening, ...carrier.rows(content), ...closing])).toThrow('format v2 ' + carrier.type + ' at seq ' + String(carrier.seq) + ' ' + carrier.path)
+    })
+    it.each([false, true])('rejects unknown kind in ' + carrier.type + ' nested=%s', (deep) => {
+      const content = deep ? [text, nested([text, nested([future])])] : [future]
+      const path = carrier.path + (deep ? '[1].content[1].content[0]' : '[0]')
+      expect(() => migrate([...opening, ...carrier.rows(content), ...closing])).toThrow(
+        'format v2 ' + carrier.type + ' at seq ' + String(carrier.seq) + ' ' + path + ': cannot safely transform unclassified message content kind "future-content"',
+      )
+    })
+  }
+
+  for (const type of ['assistant/message', 'assistant/attempt']) {
+    it.each(malformed)('rejects malformed raw block-end $kind in ' + type, ({ block, kind }) => {
+      const stream = [{ type: 'chunk', time: 4, chunk: { type: 'block-end', index: 987, block: nested([block]) } }]
+      expect(() => migrate([...opening, assistant(type, stream)])).toThrow('format v2 ' + type + ' at seq 3 data.stream[0].chunk.block.content[0]')
+      expect(() => migrate([...opening, assistant(type, stream)])).toThrow('kind "' + kind + '"')
+    })
+    it.each([{}, { blockType: null }, { blockType: 1 }, { blockType: '' }])('rejects missing or malformed block-start kind in ' + type + ' %j', (fields) => {
+      const stream = [{ type: 'chunk', time: 4, chunk: { type: 'block-start', index: 987, ...fields } }]
+      expect(() => migrate([...opening, assistant(type, stream)])).toThrow('format v2 ' + type + ' at seq 3 data.stream[0].chunk.blockType: cannot safely transform unclassified message content kind')
+    })
+    it.each([null, {}, [null], [{ type: 'chunk' }], [{ type: 'chunk', chunk: [] }], [{ type: 'chunk', chunk: { type: 'block-end' } }]])('narrows owned durable stream containers in ' + type + ' %j', (stream) => {
+      expect(() => migrate([...opening, assistant(type, stream)])).toThrow('format v2 ' + type + ' at seq 3 data.stream')
+    })
+    it.each(admitted)('preserves admitted raw block kind $type in ' + type, (block) => {
+      const stream = [{ type: 'chunk', time: 4, chunk: { type: 'block-start', index: 987, blockType: block.type } }, { type: 'chunk', time: 5, chunk: { type: 'block-end', index: 987, block } }]
+      const settled = type === 'assistant/message' && block.type === 'tool-call' ? [
+        event('tool/call', { turn: 1, step: 1, callId: 'opaque-call', name: 'read', arguments: '{"type":"future-content","seq":987}' }),
+        event('tool/result', { turn: 1, step: 1, message: { id: 'result', role: 'user', source: { kind: 'tool', callId: 'opaque-call' }, content: [{ type: 'tool-result', toolCallId: 'opaque-call', content: [text] }] } }, true),
+      ] : []
+      const input = [...opening, assistant(type, stream, [block]), ...settled, ...closing]
+      const before = JSON.stringify(input)
+      const output = migrate(input)
+      expect(restoreReleasedV3Artifact(output, new Set())).toBe(output)
+      expect(output.events.find(row => row.type === type)?.data).toEqual(input[3]!.data)
+      expect(JSON.stringify(input)).toBe(before)
+    })
+    it('accepts historical raw block chunks in ' + type, () => {
+      const stream = [{ type: 'chunk', time: 4, chunk: { type: 'block-start', index: 987, blockType: 'text' } }, { type: 'chunk', time: 5, chunk: { type: 'block-end', index: 987, block: text } }]
+      const output = migrate([...opening, assistant(type, stream), ...closing])
+      expect(restoreReleasedV3Artifact(output, new Set())).toBe(output)
+    })
+    it.each([false, true])('rejects raw block-end unknown kind in ' + type + ' nested=%s', (deep) => {
+      const block = deep ? nested([future]) : future
+      const path = 'data.stream[0].chunk.block' + (deep ? '.content[0]' : '')
+      expect(() => migrate([...opening, assistant(type, [{ type: 'chunk', time: 4, chunk: { type: 'block-end', index: 987, block } }]), ...closing])).toThrow(
+        'format v2 ' + type + ' at seq 3 ' + path + ': cannot safely transform unclassified message content kind "future-content"',
+      )
+    })
+    it('rejects raw block-start unknown kind without an end in ' + type, () => {
+      expect(() => migrate([...opening, assistant(type, [{ type: 'chunk', time: 4, chunk: { type: 'block-start', index: 987, blockType: 'future-content' } }])])).toThrow(
+        'format v2 ' + type + ' at seq 3 data.stream[0].chunk.blockType: cannot safely transform unclassified message content kind "future-content"',
+      )
+    })
+  }
+})

+ 247 - 0
packages/session/session-persistence-jsonl/tests/content-admission.spec.ts

@@ -0,0 +1,247 @@
+/** V2 content admission refuses entire generations without publishing a valid prefix. */
+
+import { Context } from '@deepseek-ai/cordis'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
+import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { basename, dirname, join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { generationLogPath, scanLog, type JsonlCompression } from '../src/format.ts'
+import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
+
+const id = SessionId('content-admission')
+const text = { type: 'text', text: 'Keep tool/code-dispatch and tools-code-mode literal. 图片' }
+const unknown = { type: 'future-block', seq: 2, text: 'Do not discard this content.' }
+const prefix: readonly (SessionFormatJsonObject & { readonly type: string })[] = [
+  { type: 'turn/start', data: { turn: 1 } },
+  { type: 'step/start', data: { turn: 1, step: 1 } },
+  { type: 'user/message', surfaceOp: 'append', data: {
+    id: 'question', role: 'user', source: { kind: 'user' }, content: [text],
+  } },
+]
+
+interface Carrier {
+  readonly name: string
+  readonly path: string
+  rows(block: SessionFormatJsonObject): readonly (SessionFormatJsonObject & { readonly type: string })[]
+  readonly targetType?: string
+  readonly targetData?: SessionFormatJsonObject
+}
+
+const compaction = {
+  compactionId: 'compact-1', summary: [text], rawOutput: [text], llmStreamCall: true,
+  shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2], shadowedTokenCount: 7,
+  provider: 'historical', model: 'historical-model',
+}
+const toolCall = { type: 'tool-call', id: 'root-call', name: 'run_code', arguments: '{"code":"return tools.read({})"}' }
+const dispatch = {
+  rootCallId: toolCall.id, parentCallId: toolCall.id, subCallId: 'read-call',
+  name: 'read', arguments: { file_path: 'audit.txt', opaque: { type: 'future-block', seq: 2 } },
+}
+
+function assistantRow(type: 'assistant/message' | 'assistant/attempt', stream: readonly SessionFormatJsonObject[]): SessionFormatJsonObject & { readonly type: string } {
+  return {
+    type, ...(type === 'assistant/message' ? { surfaceOp: 'append' } : {}),
+    data: {
+      turn: 1, step: 1, stream,
+      ...(type === 'assistant/message' ? { message: {
+        id: 'answer', role: 'assistant', content: [text],
+        source: { kind: 'model', provider: 'historical', model: 'historical-model' },
+      } } : {}),
+    },
+  }
+}
+
+const carriers: readonly Carrier[] = [{
+  name: 'team queued message', path: 'data.message.content[0]',
+  rows: block => [...prefix, { type: 'team/message/queued', data: {
+    version: 1, teamId: 'team-1', message: {
+      id: 'queued-1', senderId: 'lead', senderName: 'lead', targetId: 'worker',
+      delivery: 'quiet', content: [block],
+    },
+  } }],
+}, ...(['summary', 'rawOutput'] as const).map((field): Carrier => ({
+  name: 'compaction ' + field, path: 'data.' + field + '[0]',
+  rows: block => [
+    ...prefix,
+    { type: 'step/end', data: { turn: 1, step: 1 } },
+    { type: 'compaction/start', data: { compactionId: 'compact-1', turn: 1 } },
+    { type: 'compaction/summary', data: { ...compaction, [field]: [block] } },
+  ],
+  targetData: { ...compaction, shadowedRange: { start: 3, end: 3 }, shadowedSeqs: [3] },
+})), {
+  name: 'PTC dispatch content', path: 'data.content[0]', targetType: 'tool/ptc-dispatch',
+  rows: block => [
+    ...prefix,
+    { type: 'assistant/message', surfaceOp: 'append', data: {
+      turn: 1, step: 1, message: {
+        id: 'calling-assistant', role: 'assistant', content: [toolCall],
+        source: { kind: 'model', provider: 'historical', model: 'historical-model' },
+      },
+      stream: [
+        { type: 'tool-call-chunks', time0: 1003, index: 0, id: toolCall.id,
+          name: toolCall.name, dt: [], args: [toolCall.arguments] },
+        { type: 'chunk', time: 1004, chunk: { type: 'finish', reason: { kind: 'tool-calls' } } },
+      ],
+    } },
+    { type: 'tool/call', data: { turn: 1, step: 1, callId: toolCall.id, name: toolCall.name, arguments: toolCall.arguments } },
+    { type: 'tool/code-dispatch-start', data: dispatch },
+    { type: 'tool/code-dispatch', data: { ...dispatch, isError: false, content: [block] } },
+  ],
+}, ...(['assistant/message', 'assistant/attempt'] as const).flatMap(type =>
+  (['block-start', 'block-end'] as const).map((chunkType): Carrier => ({
+    name: type + ' ' + chunkType,
+    path: chunkType === 'block-start' ? 'data.stream[0].chunk.blockType' : 'data.stream[2].chunk.block',
+    rows: block => [...prefix, assistantRow(type, [
+      { type: 'chunk', time: 1001, chunk: {
+        type: 'block-start', index: 0, blockType: chunkType === 'block-start' ? block['type']! : 'text',
+      } },
+      { type: 'text-chunks', time0: 1002, index: 0, dt: [], texts: [text.text] },
+      { type: 'chunk', time: 1003, chunk: {
+        type: 'block-end', index: 0, block: chunkType === 'block-end' ? block : text,
+      } },
+      { type: 'chunk', time: 1004, chunk: { type: 'finish', reason: type === 'assistant/message'
+        ? { kind: 'stop' } : { kind: 'error', failure: { message: 'provider ended the attempt', code: 'SERVER' } },
+      } },
+    ])],
+  }))),
+]
+
+let root: string | undefined
+const contexts: Context[] = []
+
+beforeEach(async () => {
+  root = await mkdtemp(join(tmpdir(), 'dsh-content-admission-'))
+})
+
+afterEach(async () => {
+  try {
+    for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
+  } finally {
+    if (root !== undefined) await rm(root, { recursive: true, force: true })
+  }
+})
+
+async function mount(compression: JsonlCompression): Promise<Context> {
+  if (root === undefined) throw new Error('persistence fixture is not initialized')
+  const ctx = new Context()
+  contexts.push(ctx)
+  await ctx.plugin(JsonlSessionPersistence, { root, compression })
+  return ctx
+}
+
+function line(value: unknown): string {
+  return JSON.stringify(value) + '\n'
+}
+
+async function store(compression: JsonlCompression, rows: readonly SessionFormatJsonObject[]) {
+  if (root === undefined) throw new Error('persistence fixture is not initialized')
+  const path = generationLogPath(root, undefined, id, 2, compression)
+  const events = rows.map((row, seq) => ({ ...row, seq, time: 1001 + seq }))
+  const header = { type: 'session', version: 2, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
+  // The EOF carrier occupies a complete frame after a separately encoded valid prefix.
+  const chunks = [line(header), events.slice(0, -1).map(line).join(''), line(events.at(-1))]
+  const bytes = compression === 'none' ? Buffer.from(chunks.join(''))
+    : Buffer.concat(await Promise.all(chunks.map(chunk => compressZstdFrame(chunk))))
+  await mkdir(dirname(path), { recursive: true })
+  await writeFile(path, bytes)
+  return path
+}
+
+async function observe(path: string) {
+  const identity = await stat(path, { bigint: true })
+  return { bytes: await readFile(path), dev: identity.dev, ino: identity.ino, size: identity.size }
+}
+
+async function expectOnlyGenerations(paths: readonly string[]) {
+  // A released write lease may leave session.lock, but no staged or published prefix may remain.
+  expect((await readdir(dirname(paths[0]!))).filter(name => name !== 'session.lock').sort())
+    .toEqual(paths.map(path => basename(path)).sort())
+}
+
+const modes = (['none', 'zstd'] as const).flatMap(compression =>
+  (['read', 'write'] as const).map(access => ({ compression, access })),
+)
+
+describe.each(modes)('V2 content EOF refusal ($compression, $access)', ({ compression, access }) => {
+  it.each(carriers)('refuses $name without source mutation or prefix publication', async (carrier) => {
+    const rows = carrier.rows(unknown)
+    const tail = rows.at(-1)!
+    const path = await store(compression, rows)
+    const original = await observe(path)
+    const ctx = await mount(compression)
+    const opened = ctx.sessionPersistence.open(id, access).then(async (handle) => {
+      try { await handle.read() } finally { await handle.close() }
+    })
+    await expect(opened).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
+    await expect(opened).rejects.toMatchObject({
+      message: 'format v2 ' + tail['type'] + ' at seq ' + String(rows.length - 1) + ' '
+        + carrier.path + ': cannot safely transform unclassified message content kind "future-block"'
+        + '; source v2 artifact remains unchanged (raw log: ' + path + ')',
+      location: { kind: 'jsonl', path },
+    })
+    expect(await observe(path)).toEqual(original)
+    await expectOnlyGenerations([path])
+  })
+})
+
+describe.each(['none', 'zstd'] as const)('V2 admitted content publication (%s)', (compression) => {
+  it.each(carriers)('preserves $name on read, publication, and fresh reopen', async (carrier) => {
+    const rows = carrier.rows(text)
+    const tail = rows.at(-1)!
+    const path = await store(compression, rows)
+    const original = await observe(path)
+    const ctx = await mount(compression)
+    const reader = await ctx.sessionPersistence.open(id, 'read')
+    const restored = await (async () => {
+      try {
+        expect(reader.header.version).toBe(3)
+        return await reader.read()
+      } finally { await reader.close() }
+    })()
+    expect(restored.events.map(event => event.type)).toEqual([
+      'turn/start', 'step/start', 'system/message',
+      ...rows.slice(2).map(row => row['type'] === 'tool/code-dispatch-start' ? 'tool/ptc-dispatch-start'
+        : row['type'] === 'tool/code-dispatch' ? 'tool/ptc-dispatch' : row['type']),
+    ])
+    expect(restored.events.at(-1)).toMatchObject({
+      type: carrier.targetType ?? tail['type'], seq: rows.length,
+    })
+    expect(restored.events.at(-1)?.data).toEqual(carrier.targetData ?? tail['data'])
+    expect(await observe(path)).toEqual(original)
+    await expectOnlyGenerations([path])
+
+    const writer = await ctx.sessionPersistence.open(id, 'write')
+    try {
+      expect((await writer.read()).events).toEqual(restored.events)
+      await writer.flush()
+    } finally { await writer.close() }
+    const successor = generationLogPath(root!, undefined, id, 3, compression)
+    const published = await readFile(successor)
+    let decoded = published
+    if (compression === 'zstd') {
+      const { frames, tornStart } = scanZstdFrames(published)
+      expect(tornStart).toBeUndefined()
+      decoded = Buffer.concat(await Promise.all(frames.map(frame =>
+        decompressZstdFrame(published.subarray(frame.start, frame.end)),
+      )))
+    }
+    expect(scanLog(decoded).events).toEqual(restored.events)
+    expect(await observe(path)).toEqual(original)
+    await expectOnlyGenerations([path, successor])
+
+    await ctx.fiber.dispose()
+    const fresh = await mount(compression)
+    const reopened = await fresh.sessionPersistence.open(id, 'read')
+    try {
+      expect(reopened.header.version).toBe(3)
+      expect((await reopened.read()).events).toEqual(restored.events)
+    } finally { await reopened.close() }
+    expect(await observe(path)).toEqual(original)
+    expect(await readFile(successor)).toEqual(published)
+    await expectOnlyGenerations([path, successor])
+  })
+})