Merge pull request #3799 from deepseek-harness/session-v3/migration-coverage

test(session): 补齐 V3 迁移组合回归并统一升级规格
This commit is contained in:
Tianyi Cui
2026-09-08 22:03:56 +08:00
committed by GitHub
29 changed files with 1171 additions and 109 deletions
@@ -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-06-18-session-surface.md
2026-06-18-session-surface.md: 921f607a0cdd4b0d7a3fa8f378b776869949cc64
2026-06-18-session-surface.zh.md: f7dd544d4882a15fee39fd64efce2575726741fe
2026-06-18-session-surface.md: 93ea55883dedd943fe1ffac67a9842c962ca6dac
2026-06-18-session-surface.zh.md: 54ecb1162bc46007dfcbb7d8cb39075d52171567
@@ -37,7 +37,7 @@ Delta processing is O(1) when no new events and O(new events) when new events ar
### Persistence
The fields are serialized as top-level JSON properties. JSONL preserves placement and provenance without a separate column mapping. The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns exact replacement keys, strict acceptance, and the adjacent v2-to-v3 conversion; this note retains ordered-projection ownership and replacement rationale.
The fields are serialized as top-level JSON properties. JSONL preserves placement and provenance without a separate column mapping. The [V3 canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns exact replacement keys and strict-acceptance rationale; the [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) owns historical conversion. This note retains ordered-projection ownership and replacement rationale.
### Crash recovery
@@ -47,7 +47,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: source lists are non-empty, unique, earlier, and known; `assistant/message` carries no source list; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Released validation and conversion belong to their versioned migration edges rather than generic Session code. Valid historical v2 already requires every surface marker; v2-to-v3 never supplies missing placement on source events. Its synthetic system events carry explicit placement.
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Released validation and conversion belong to their versioned migration edges rather than generic Session code; see the [V2-to-V3 placement rules](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes).
## Alternatives considered
@@ -37,7 +37,7 @@ surface 元数据仅属于四种 surface 事件类型(`system/message`、`user
### 持久化
这些字段作为顶层 JSON 属性序列化。JSONL 无需单独列映射即可保留位置与来源。[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责精确替换键严格接纳与相邻 v2-to-v3 转换本文继续负责有序投影的所有权与替换依据。
这些字段作为顶层 JSON 属性序列化。JSONL 无需单独列映射即可保留位置与来源。[V3 规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责精确替换键严格准入依据;[V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)负责历史转换本文继续负责有序投影的所有权与替换依据。
### 崩溃恢复
@@ -47,7 +47,7 @@ surface 元数据仅属于四种 surface 事件类型(`system/message`、`user
`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs``surfaceOp`source list 必须非空、唯一、更早且已知;`assistant/message` 不携带 source listreplacement endpoint 必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选 invariant service 提供的规则。
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。已发布格式的校验与转换属于各自版本化迁移边,而不属于通用 Session 代码。有效的历史 v2 已要求每个 surface 标记;v2-to-v3 从不补充源事件缺失的位置。其合成系统事件携带显式位置声明
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。已发布格式的校验与转换属于各自版本化迁移边,而不属于通用 Session 代码;参见 [V2 到 V3 位置规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)
## 曾考虑的替代方案
@@ -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: dc651350330ddb9a78b31bbd07a1f52ac758adfa
2026-08-31-released-session-format-migrations.zh.md: 3bbcca1b0ca6d764cce23b75ee02af68e7d080c6
2026-08-31-released-session-format-migrations.md: d92b1c34dee293d53ffb8ed5bd77958eba34eaad
2026-08-31-released-session-format-migrations.zh.md: b2e6fb8e2c3018ef3fdca9cd5ac2204f705dbc90
@@ -66,13 +66,13 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
### Adjacent version ownership
V2→V3 refuses source delivery markers claiming V3 acceptance: a marker ignored in V2 must not become an active V3 upload watermark merely because the header changes. Other non-current marker generations retain their ignored meaning. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
The [V2-to-V3 delivery guards](../../../../packages/session/session-format-v2-to-v3/README.md#delivery-guards) prevent a marker ignored in the source generation from becoming an active upload watermark merely because the header changes. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
V2→V3 owns the [system-prompt structural conversion](2026-09-02-system-prompt-as-surface-node.md), a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. The edge preserves source event order and request meaning while inserting system-head events and remapping local sequence references and the inherited cut. Unknown events are not unconditionally safe to retain when cardinality changes; the edge refuses events it cannot transform safely. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
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.
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. V2→V3 validates and derives its cut from markers; 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.
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.
All structural changes compose in the one unreleased V2→V3 edge; feature or review order does not allocate extra Session format versions. V0, V1, and V2 generations remain byte-frozen, and migration publishes only the final V3 successor. The unreleased target can evolve until release, but an already-written V3 file does not rerun its incoming migration. Integration tests therefore require isolated disposable homes and unchanged historical inputs rather than rewriting committed generations.
@@ -120,6 +120,8 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr
## Verification
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.
### 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`.
@@ -66,13 +66,13 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
### 相邻版本所有权
V2→V3 拒绝声称 V3 已接受投递的源标记:V2 中被忽略的标记不能仅因 header 变化就成为有效的 V3 上传水位。其他非当前代际标记仍保持被忽略的含义。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
[V2 到 V3 投递保护](../../../../packages/session/session-format-v2-to-v3/README.zh.md#delivery-guards)防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
V2→V3 负责[系统提示词结构转换](2026-09-02-system-prompt-as-surface-node.zh.md),以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。该迁移边在插入系统头节点事件、重映射本地序号引用与继承截点的同时,保留源事件顺序和请求含义。事件数量变化时,未知事件并非无条件可以安全保留;迁移边拒绝无法安全转换的事件。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
[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)负责包接线、当前消费方、快照后继代际与验证命令。
预设更名覆盖创建头部和每条选择事件,因为最新选择决定恢复时的预设,而更早的选择决定历史 fork 的预设。只改写最后一条选择会丢失这种区别。已发布的 `code` 标识表示旧内置预设;迁移不依赖已安装的预设列表,因此相同字节在每台主机上产生相同结果。原生 V3 的自定义标识仍可使用,无需全局运行时别名。
源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2→V3 校验并从标记推导截点;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
所有结构变更组合在唯一且尚未发布的 V2→V3 迁移边中;功能或评审顺序不分配额外 Session 格式版本。V0、V1、V2 代际保持字节冻结,迁移只发布最终 V3 后继代际。未发布的目标可以持续演化至发布,但已经写出的 V3 文件不会重新执行入边迁移。因此,集成测试必须使用隔离、可丢弃的 home 和未变更的历史输入,而非改写已提交代际。
@@ -120,6 +120,8 @@ POSIX publication 使用 hard-link creation 加目录 syncWindows 使用 no-o
## 验证
迁移规范要求分别提供转换、保留与拒绝的证据。直接迁移边和原生 V3 测试不能证明有种子的多跳发布:前代 assistant 流折叠会在 V3 插入系统事件前改变源坐标。因此,经过真实目录与 JSONL 提供方的测试需要原始及压缩的 V0/V1 输入、映射后的引用和继承切点、发布/重新打开等价性、前代字节不变,以及不产生中间代。覆盖率百分比本身不能证明这些跨阶段关系;组合断言必须比较结果历史与拒绝效果。
### Benchmark 输入与口径
Benchmark 使用 Node v24.18.0 和一份 116,228,655-byte 的 v0 Zstandard 日志,其中包含 317,540 个 frame 与 454,151 个 physical row。老 reader 会恢复 9,143,111 个展开后的 v0 eventmigration 会生成 72,784 个 current v2 eventartifact SHA-256 为 `fa16ff9472ca350595a3112c20a3db79655bc2673973469987ecaf2a57ebd17c`
@@ -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-02-system-prompt-as-surface-node.md
2026-09-02-system-prompt-as-surface-node.md: 432b05b4c9e925b7bf3b5513e6c10efacacab95e
2026-09-02-system-prompt-as-surface-node.zh.md: 9e45c9b7fd2ee6d075f3fac4a9acb57f2a09add0
2026-09-02-system-prompt-as-surface-node.md: dc0d22b2fb927ad288415346bea9d0c2793cf000
2026-09-02-system-prompt-as-surface-node.zh.md: 368684d85cb7ddf5d0be63bce905ce48e86cb49f
@@ -58,15 +58,11 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r
### V2-to-V3 structural conversion
The [V2-to-V3 migration](../../../../packages/session/session-format-v2-to-v3/README.md) converts each V2 `request/header.system` into the protected `system/message` head and removes the retired header field. It inserts an empty head immediately after the first `step/start`, then replaces that head immediately before a header whose prompt differs. Inserted messages have deterministic IDs. The transform preserves source event order, reconstructed request meaning, and the exact IDs and content of every other message. A native V3 writer emits its normal prompt head; the migrated event layout is semantically equivalent, not byte-identical to a native recording.
Inserted events shift local sequence positions. The transform remaps local sequence references, replacement ranges, and the inherited cut; historical delivery/version facts and captured references to other sessions retain their source values. These historical coordinates must not be relabeled as acknowledgements of the transformed V3 log.
Strict migration refuses unknown events whose payloads cannot be safely transformed, unsupported surface history before the first step, and a prompt transition outside an open step. Such a V2 source can be valid while having no order-preserving conversion under the current core step invariant. Refusal leaves the original files unchanged; it does not reorder source events or relax the invariant to force a conversion. The V3 reader rejects the retired `header.system` field and validates system-message payloads and protected-head rewrites rather than relying on TypeScript field omission.
The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#system-head) owns system-head conversion and message identities; its [reference rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) and [source refusal](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) define preservation and unsupported inputs. The migrated layout is semantically equivalent to native requests, not byte-identical to a native recording. A valid V2 source can lack an order-preserving conversion under the current step invariant; refusing it is preferable to moving history or relaxing ownership. Historical acceptance coordinates must not become acknowledgements of the transformed log.
The [released-format policy](2026-08-31-released-session-format-migrations.md) keeps V0, V1, and V2 generations byte-frozen and publishes only V3 successors. V3 is one unreleased target, not a new version per feature; it can evolve before release, so integration requires disposable homes. An existing V3 generation does not rerun V2-to-V3. Projection-cache version 4 is independent of the Session format and does not imply Session V4.
[Canonical-envelope conversion](2026-09-06-v3-canonical-session-envelopes.md) follows structural insertion and reference remapping within the same V2-to-V3 edge. It canonicalizes replacement endpoints on original and synthetic events; only that final stage preserves its input event count and sequence coordinates, not the whole migration.
The [canonical-envelope specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) defines composition with the structural conversion; the [canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns the strict-acceptance rationale.
## Alternatives considered
@@ -58,15 +58,11 @@ Status: implemented
### V2-to-V3 结构转换
[V2-to-V3 迁移](../../../../packages/session/session-format-v2-to-v3/README.zh.md)把每个 V2 `request/header.system` 转为受保护的 `system/message` 头节点,并删除已退役的 header 字段。它紧接首个 `step/start` 插入空头节点,再在提示词不同的 header 之前立即替换该节点。插入消息的 ID 是确定性的。转换保留源事件顺序、重建请求的含义,以及所有其他消息的精确 ID 和内容。原生 V3 writer 正常写出提示词头节点;迁移事件布局与原生记录语义等价,但并非逐字节相同
插入事件会移动本地序列位置。转换重映射本地序号引用、替换范围与继承截点;历史投递/版本事实,以及捕获的其他会话引用保留源值。这些历史坐标不得被重新标记为对转换后 V3 日志的确认。
严格迁移拒绝无法安全转换载荷的未知事件、首个步骤前不受支持的 surface 历史,以及开放步骤之外的提示词转换。这样的 V2 源可以有效,但在当前核心步骤不变量下没有保持顺序的转换方式。拒绝不会改动原始文件;它不会重排源事件,也不会放宽不变量来强行转换。V3 读取器拒绝已退役的 `header.system` 字段,并校验系统消息载荷与受保护头节点的重写,而非依赖 TypeScript 省略字段。
[V2V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#system-head)负责系统头节点转换与消息身份;其[引用规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)和[源拒绝](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)定义保留内容与不支持的输入。迁移布局与原生请求语义等价,而非与原生录制逐字节相同。有效 V2 源在当前步骤不变量下可能没有保持顺序的转换方式;拒绝它优于移动历史或放宽归属。历史接收坐标不得变为对转换后日志的确认
[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保持 V0、V1、V2 代际字节冻结,并且只发布 V3 后继代际。V3 是一个尚未发布的目标,而不是每个功能一个新版本;它在发布前可以演化,因此集成必须使用可丢弃的 home。已有 V3 代际不会重跑 V2-to-V3。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4。
[规范信封转换](2026-09-06-v3-canonical-session-envelopes.zh.md)在同一 V2-to-V3 迁移边中位于结构插入与引用重映射之后。它规范化原始与合成事件的替换端点;只有这个最终阶段保留其输入事件数与序列坐标,而不是整个迁移都保留
[规范信封规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)定义与结构转换的组合;[规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责严格准入的依据
## Alternatives considered
@@ -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-06-v3-canonical-session-envelopes.md
2026-09-06-v3-canonical-session-envelopes.md: 65e428d2662d8dd64f5ee7e0ab5ef9aab85aca3b
2026-09-06-v3-canonical-session-envelopes.zh.md: a1ec9daec2bd24016d3b4089462d0a31387108df
2026-09-06-v3-canonical-session-envelopes.md: 17ec331cc1ea21f31d0b51eab86d22725b6deef8
2026-09-06-v3-canonical-session-envelopes.zh.md: a18bb3b4d71c164e3fba7288cc686a1cb7c7b4df
@@ -24,13 +24,7 @@ The generic Gateway client returns raw outputs without validating them. The exis
### Released V2 to V3 conversion
The [adjacent migration](../../../../packages/session/session-format-v2-to-v3/README.md) changes the Session header version to 3. Its structural stage inserts an empty system head immediately after the first `step/start`, emits protected-head replacements before changed historical request prompts including clears, removes every `header.system`, and remaps audited sequence references and inherited cuts. It preserves source-event chronology and request meaning, not source event count or sequence coordinates. Unknown historical events are refused even when ignorable because their sequence dependencies have not been audited. Metadata-only logs gain no head.
Canonicalization then renames exact replacement objects on original and synthetic envelopes from `op/start/end` to `op/startSeq/endSeq` and omits empty `tools` and `adapterDefaults`. Only this final stage preserves its input event count, coordinates, timestamps, order, and inherited cut. Embedded streams, model-visible content, historical identities, and unrelated payload values remain intact. Valid source surface events require placement; missing markers are refused, never synthesized as append operations. Contradictory tool errors are refused, not repaired.
Frozen v0-to-v1 and v1-to-v2 semantics remain unchanged. V3 restoration first validates canonical envelopes, system payloads, step ownership, and protected-head operations. Frozen relationship validation then receives a private system/PTC/repair view composed with the canonical-endpoint view, retaining the actual target generation for generation-sensitive checks. Historical repair-ID suffixes remain identities rather than current coordinates. The restorer returns the original V3 artifact; the internal view is not a read alias or a replacement for structural validation.
The V3 codec validates event-local rules before encoding and after physical framing and provenance decoding. Raw structural and unsupported-event refusal precede recoverable decoding, including required obsolete PTC tags after corrupt rows; recovery cannot hide them. Strict decoding rejects canonical errors immediately. Committed-prefix recovery withholds the first canonically invalid event and all following events; a later `turn/end` proves the invalid suffix was committed and rejects it. The inherited cut counts only accepted markers: a discarded suffix cannot advance it, and a seeded prefix without an accepted marker is refused. This keeps native current-generation reads subject to V3 acceptance even when they bypass artifact restoration.
The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) owns the complete historical conversion, its [canonicalization rules](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes), and [native admission and recovery](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission). Keeping these rules together prevents a cardinality-preserving canonicalization step from being mistaken for an identity migration. Frozen relationship validation uses private views rather than runtime aliases; the original V3 artifact remains authoritative.
## Alternatives considered
@@ -46,7 +40,7 @@ The V3 codec validates event-local rules before encoding and after physical fram
Typed events, persistence, and browser history agree on required placement and event-local failure semantics. Malformed records fail before projection rather than disappearing from model history. Migration gives up best-effort recovery of contradictory records; retained source generations remain untouched under the [released-format publication policy](2026-08-31-released-session-format-migrations.md).
This decision partially supersedes envelope representation details in the [session surface](2026-06-18-session-surface.md) and [reconstructable requests](2026-07-05-reconstructable-requests.md) notes. They remain active for ordered projection and logged request ownership. The [system-prompt surface-node decision](2026-09-02-system-prompt-as-surface-node.md) retains prompt ownership, protected-head semantics, and structural migration. The [V2 embedded-stream decision](2026-09-01-v2-embedded-assistant-streams.md) remains active for attempt settlement, exact stream evidence, and cardinality-changing migration; V3 preserves those decisions.
This decision partially supersedes envelope representation details in the [session surface](2026-06-18-session-surface.md) and [reconstructable requests](2026-07-05-reconstructable-requests.md) notes. They remain active for ordered projection and logged request ownership. The [system-prompt surface-node decision](2026-09-02-system-prompt-as-surface-node.md) retains prompt ownership, protected-head semantics, and migration rationale. The [V2 embedded-stream decision](2026-09-01-v2-embedded-assistant-streams.md) remains active for attempt settlement, exact stream evidence, and cardinality-changing migration; V3 preserves those decisions.
## Verification
@@ -24,13 +24,7 @@ Session 格式 V3 使用一种规范事件信封。每个 `system/message`、`us
### 已发布 V2 到 V3 的转换
[相邻迁移](../../../../packages/session/session-format-v2-to-v3/README.zh.md)将 Session 头版本改为 3。其结构阶段在首个 `step/start` 后立即插入空系统头节点,在历史请求提示发生变化(包括清空)前输出受保护头节点替换,移除每个 `header.system`,并重映射经过审计的序列引用与继承切点。它保留源事件时序与请求含义,而非源事件数或序列坐标。未知历史事件即使可忽略也会被拒绝,因为其序列依赖尚未经审计。仅含元数据的日志不添加头节点
随后规范化将原始与合成信封上的精确替换对象从 `op/start/end` 重命名为 `op/startSeq/endSeq`,并省略空的 `tools``adapterDefaults`。只有这个最终阶段保留其输入事件数、坐标、时间戳、顺序与继承切点。嵌入式 stream、模型可见内容、历史身份与无关载荷值保持原样。有效源 surface 事件要求位置声明;缺失标记会被拒绝,绝不会合成为 append 操作。矛盾的工具错误会被拒绝,而非修复。
冻结的 v0-to-v1 与 v1-to-v2 语义保持不变。V3 恢复首先验证规范信封、系统载荷、步骤归属与受保护头节点操作。随后冻结的关系校验接收由私有 system/PTC/repair 视图与规范端点视图组合而成的输入,并为代次敏感检查保留实际目标代次。历史修复 ID 后缀仍是身份,而非当前坐标。恢复器返回原始 V3 产物;内部视图既不是读取别名,也不替代结构校验。
V3 编解码器在编码前及物理分帧与来源解码后验证事件本地规则。原始结构与不受支持事件的拒绝先于可恢复解码,包括损坏行之后必需的旧 PTC 标签;恢复不能隐藏它们。严格解码立即拒绝规范错误。已提交前缀恢复不产出首个规范无效事件及其后所有事件;后续 `turn/end` 证明无效后缀已提交,因此会拒绝该后缀。继承切点只统计已接纳的标记:丢弃的后缀不能推进它,缺少已接纳标记的种子前缀会被拒绝。这样,即使当前代次原生读取绕过产物恢复,也仍受 V3 接纳规则约束。
[V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)负责完整历史转换、[规范化规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)及[原生准入与恢复](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)。将这些规则集中在一起,可以避免把保持事件数量的规范化步骤误认为恒等迁移。冻结的关系校验使用私有视图而非运行时别名;原始 V3 产物仍具权威性
## 曾考虑的替代方案
@@ -46,7 +40,7 @@ V3 编解码器在编码前及物理分帧与来源解码后验证事件本地
类型化事件、持久化与浏览器历史对必填位置和事件本地失败语义保持一致。畸形记录在投影前失败,而不会从模型历史中消失。迁移放弃对矛盾记录的尽力恢复;[已发布格式的发布策略](2026-08-31-released-session-format-migrations.zh.md)保证保留的源代次不被修改。
本决策部分取代[会话 surface](2026-06-18-session-surface.zh.md)与[可重建请求](2026-07-05-reconstructable-requests.zh.md)说明中的信封表示细节。它们继续负责有序投影与已记录请求的所有权。[系统提示词 surface 节点决策](2026-09-02-system-prompt-as-surface-node.zh.md)保留提示所有权、受保护头节点语义与结构迁移。[V2 嵌入式 stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)继续负责尝试结算、精确 stream 证据与改变事件数量的迁移;V3 保留这些决策。
本决策部分取代[会话 surface](2026-06-18-session-surface.zh.md)与[可重建请求](2026-07-05-reconstructable-requests.zh.md)说明中的信封表示细节。它们继续负责有序投影与已记录请求的所有权。[系统提示词 surface 节点决策](2026-09-02-system-prompt-as-surface-node.zh.md)保留提示所有权、受保护头节点语义与迁移依据。[V2 嵌入式 stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)继续负责尝试结算、精确 stream 证据与改变事件数量的迁移;V3 保留这些决策。
## 验证
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-ptc.md
2026-06-15-ptc.md: 94a0fd3492c049ad2ce6fe3634422b1569dc80b8
2026-06-15-ptc.zh.md: 12e321b111d1b6a63068f4e65fd836ba4918e086
2026-06-15-ptc.md: 43bd4a4fd5c49a449ceccb7f1889b80b0844214d
2026-06-15-ptc.zh.md: a6aaf203186ad3023ce39a9df04fc1b233db88eb
@@ -58,7 +58,7 @@ Each sub-dispatch appends a log-only `tool/ptc-dispatch-start` event at pool ent
New sub-calls use `<parent>:ptc:<n>` ids, numbered in submission order. All call ids are opaque to consumers: migration preserves every historical id byte-for-byte, including `:code:` substrings, so dispatch pairs, spill references, and other correlations remain intact. The bridge attributes forwarded image context to `{ kind: 'plugin', plugin: 'tools-ptc' }`.
The [v2-to-v3 stage](../../../../packages/session/session-format-v2-to-v3/README.md) maps the exact `tool/code-dispatch-start` and `tool/code-dispatch` event tags to their PTC names. It maps `tools-code-mode` to `tools-ptc` only in plugin-kind sources at `user/message.data.source`, `agent/inbox/spliced.data.inserted[].source`, and `session/title-llm-request.data.messages[].source`. Other fields, arbitrary text, opaque payloads, and ids remain unchanged. This is an adjacent historical conversion, not an alias accepted by the native v3 reader: old required event tags are refused; unknown events marked `ignorable` retain the normal admission policy. Source-v2 events already named `tool/ptc-dispatch` or `tool/ptc-dispatch-start` are rejected even when ignorable because migration must not promote an opaque extension into a reserved v3 lifecycle event. Frozen predecessor modules and committed historical generations remain unchanged.
The [V2-to-V3 PTC specification](../../../../packages/session/session-format-v2-to-v3/README.md#ptc-vocabulary) owns exact historical tag and attribution conversion; [native V3 admission](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) owns predecessor-tag refusal. These are not runtime aliases: an opaque extension must not acquire PTC lifecycle meaning merely through a version change.
### The code-runtime seam
@@ -58,7 +58,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
新子调用使用 `<parent>:ptc:<n>` 标识,按提交顺序编号。消费者将所有 call id 视为不透明值:迁移逐字节保留每个历史标识,包括 `:code:` 子串,因此分发事件对、spill 引用及其他关联保持完整。桥接层将转发图片上下文的来源标记为 `{ kind: 'plugin', plugin: 'tools-ptc' }`
[v2-to-v3 阶段](../../../../packages/session/session-format-v2-to-v3/README.zh.md) 将精确匹配的 `tool/code-dispatch-start``tool/code-dispatch` 事件标签映射为对应的 PTC 名称。它仅在 `user/message.data.source``agent/inbox/spliced.data.inserted[].source``session/title-llm-request.data.messages[].source` 中,针对 plugin 类型来源将 `tools-code-mode` 映射为 `tools-ptc`。其他字段、任意文本、不透明载荷和标识保持不变。这是相邻历史版本转换,不是原生 v3 读取器接受的别名:旧的必需事件标签会被拒绝;标记为 `ignorable` 的未知事件保留通常的准入规则。源 v2 中已命名为 `tool/ptc-dispatch``tool/ptc-dispatch-start` 的事件即使可忽略也会被拒绝,因为迁移不得将不透明扩展提升为 v3 保留的生命周期事件。冻结的前代模块和已提交的历史代保持不变
[V2 到 V3 PTC 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#ptc-vocabulary)负责精确的历史标签与归属转换;[原生 V3 准入](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)负责前代标签拒绝。这些不是运行时别名:不透明扩展不能仅因版本变化就获得 PTC 生命周期含义
### code-runtime seam
@@ -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/cookbook/adding-a-session-format-version.md
adding-a-session-format-version.md: 2212006b26ace97403ec8499c3e83c29e4dcbd4b
adding-a-session-format-version.zh.md: 1364960766871792ad158fefcd1422c89eccaf2c
adding-a-session-format-version.md: c2d3b966c4ca2780e5ddef933f57715721a9dd43
adding-a-session-format-version.zh.md: 6bb93f5ff92d38acc538c53b0ff5c477bf71915b
@@ -30,7 +30,7 @@ Use disposable, isolated Harness homes for unreleased integration testing. An in
<a id="add-an-identity-edge"></a>
## 2. Add an identity edge
Follow the package checklist to create [`packages/session/session-format-v2-to-v3`](../../packages/session/session-format-v2-to-v3/README.md) as a library, not a mounted plugin. Start with a header conversion from 2 to 3 and a body conversion that preserves accepted event payloads, sequence numbers, references, timestamps, ordering, and the inherited cut. Identity describes the logical event conversion, not identical file bytes: the header and filename identify V3.
Follow the package checklist to create a library, not a mounted plugin. An identity body conversion is only an initial wiring scaffold; the integrated [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) defines the actual transformations and preservation rules. Do not treat its structural conversion as an identity edge.
Declare `dsh.sessionFormatMigration` in the package manifest with `from: 2`, `to: 3`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse `releasedV2SessionFormatCodec` from the preceding edge and depend on that package; do not copy or redefine the released V2 codec. Export the V3 codec and validators from the new package. Add the new edge as a direct dependency of the catalog and add the workspace's TypeScript paths and project references.
@@ -51,7 +51,7 @@ Implement `transformEvent(event, context)`, `transformRun(run, context)`, and `f
Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test V0→V1→V2→V3 and V1→V2→V3 with seeded Sessions, not just direct V2 input. Never substitute zero for an unknown cut.
Define each edge's event admission and transformation rules explicitly. The V2→V3 identity skeleton preserves installed ordinary event additions and unknown events carrying `ignorable: true`; unknown required events still refuse. This relies on unchanged payloads, sequence numbers, references, and ordering. The [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) instead uses a frozen inventory and refuses unknown events, including ignorable ones. Do not generalize either policy to every edge. A child that changes structure or event positions must classify source events, payload members, and references, and explicitly decide whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported V3 transformation.
Define each edge's event admission and transformation rules explicitly; the [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) owns this edge's policy. The [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) owns the preceding edge's policy. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation.
Prove strict restoration through `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })`, feeding rows in order and calling `finish()`. This exercises physical decoding, the complete chain, and installed current Session validation. Production's recoverable/transformed policy is not a replacement for strict fixture and publication verification. Preserve documented historical validation exceptions rather than claiming stricter source validation than the edge actually performs.
@@ -30,7 +30,7 @@
<a id="add-an-identity-edge"></a>
## 2. 添加恒等迁移边
按照包检查清单,将 [`packages/session/session-format-v2-to-v3`](../../packages/session/session-format-v2-to-v3/README.zh.md) 创建为库,而非挂载插件。先将 header 从 2 转换为 3,并在正文转换中保留所有可接受事件的 payload、序号、引用、时间戳、顺序和继承截点。恒等指逻辑事件转换,而非文件字节完全相同:header 和文件名标识 V3
按照包检查清单创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架;集成后的 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)定义实际转换与保留规则。不要将其结构转换视为恒等迁移边
在包 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含 `from: 2``to: 3`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边的 `releasedV2SessionFormatCodec`,并依赖该包;不要复制或重新定义已发布 V2 codec。从新包导出 V3 codec 和校验器。将新迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。
@@ -51,7 +51,7 @@ pnpm run gen-session-format-catalog
继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount``finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并用有种子的 Session 测试 V0→V1→V2→V3 和 V1→V2→V3,而非仅测试直接 V2 输入。绝不以零替代未知截点。
显式定义每条迁移边的事件准入与变换规则。V2→V3 恒等骨架保留已安装的普通事件新增项,以及带有 `ignorable: true` 的未知事件;未知必需事件仍会被拒绝。这依赖于 payload、序号、引用与顺序均不变。[Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)则采用冻结清单,并拒绝所有未知事件,包括可忽略事件。不要将任一策略推广到所有迁移边。改变结构或事件位置的子变更必须分类源事件、payload 成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的 V3 变换。
显式定义每条迁移边的事件准入与变换规则[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)负责本迁移边的策略。[Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)负责前代迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的换。
通过 `sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })` 验证严格恢复,按顺序传入各行并调用 `finish()`。这会执行物理解码、完整迁移链与已安装当前 Session 校验。生产环境的 recoverable/transformed 策略不能替代 fixture(测试前置数据)和发布验证所需的严格校验。保留已记录的历史校验例外,不要宣称源校验比迁移边实际执行的更严格。
@@ -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-catalog/README.md
README.md: 1ca40f230e604b77b88044d4fef54bf8b9ec6d32
README.zh.md: 198fef8dd944578c5a078ffb409d31459da8523a
README.md: 5550dd90b4f57ac24f34bc05d04a9e075a8dce72
README.zh.md: b80b3bb6876c00e4dc33ae5bb2aa7eb48fdcb7b5
@@ -66,7 +66,7 @@ The catalog contains all supported historical readers directly. A profile cannot
- [Migration machinery](../session-format/README.md) — catalog construction and dispatch behavior.
- [Released v0 to v1 edge](../session-format-v0-to-v1/README.md) — codec and validator ownership.
- [Released v1 to v2 edge](../session-format-v1-to-v2/README.md) — Assistant stream embedding and cardinality-changing reference remapping.
- [Released v2 to v3 edge](../session-format-v2-to-v3/README.md) — identity event restoration.
- [Released V2 to V3 specification](../session-format-v2-to-v3/README.md#v2-to-v3-specification) — transformations, preservation, and refusal.
- [JSONL persistence](../session-persistence-jsonl/README.md) — immutable generation naming and exclusive publication.
-----
@@ -66,7 +66,7 @@ Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transform
- [迁移机制](../session-format/README.zh.md)——目录构造与分派行为。
- [已发布 v0 到 v1 迁移边](../session-format-v0-to-v1/README.zh.md)——编解码器与校验器所有权。
- [已发布 v1 到 v2 迁移边](../session-format-v1-to-v2/README.zh.md)——Assistant stream 嵌入与基数变化引用重映射。
- [已发布 v2 到 v3 迁移边](../session-format-v2-to-v3/README.zh.md)——恒等事件还原
- [已发布 V2 到 V3 规范](../session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)——转换、保留与拒绝
- [JSONL 持久化](../session-persistence-jsonl/README.zh.md)——不可变 generation 命名与排他发布。
-----
@@ -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: d14dc7a374e03809b0138d6f9198837a063515e8
README.zh.md: b16670407e7932791ed8cc0058bd212c41a30143
README.md: 2aa143bb7872acb1541eb209cbc1cb6a9fcfd1eb
README.zh.md: d0839198ca6dce91eeda2a4e17ce90fdcd33a33a
@@ -1,5 +1,5 @@
---
description: "Restore released-v2 system prompts as protected v3 messages and canonicalize envelopes while preserving historical requests."
description: "The complete V2-to-V3 Session conversion: system heads, audited references, PTC and preset names, canonical envelopes, preservation, and refusal."
kind: "package-library"
---
@@ -9,11 +9,20 @@ English | [中文](README.zh.md)
## Summary
This library restores released-v2 system prompts as protected v3 messages, canonicalizes event envelopes, and translates durable PTC vocabulary. It preserves historical request meaning, source-event chronology, timestamps, message identities, and inherited ownership while remapping audited sequence references. Persistence consumes it through the static Session format catalog. It does not publish or modify durable files.
Restore supported released V2 Sessions as V3 without changing historical request meaning. This page is the single specification for this adjacent edge: what it transforms, preserves, and refuses, followed separately by native V3 admission. The library promotes system prompts into messages, remaps local event references, translates PTC and preset names, and canonicalizes envelopes. Persistence consumes it through the static catalog; the library does not read or publish files.
## Table of Contents
- [Use this package](#use-this-package)
- [V2-to-V3 specification](#v2-to-v3-specification)
- [Header and preset references](#header-and-presets)
- [System head and message identities](#system-head)
- [Sequence references and inheritance](#sequence-references)
- [PTC vocabulary](#ptc-vocabulary)
- [Canonical envelopes and tool errors](#canonical-envelopes)
- [Delivery guards](#delivery-guards)
- [Source audit and refusal](#source-audit)
- [Native V3 admission](#native-v3-admission)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
@@ -27,23 +36,102 @@ This library restores released-v2 system prompts as protected v3 messages, canon
### When to use it
Use the [catalog](../session-format-catalog/README.md) for restoration. Direct imports serve catalog assembly and tests; this library is not mounted in a Cordis composition.
Use the [catalog](../session-format-catalog/README.md) to restore a Session. Direct imports serve catalog assembly and tests; this library has no Cordis mount configuration. The [public exports](src/index.ts) provide the migration declaration, released V2 source codec, V3 target codec, target header validator, and target restorer.
### Entry point
The header-only operation does not convert or validate an event body:
```text
const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
```
The header version becomes 3. The exact legacy preset id `code` becomes `ptc` in `header.agentPreset` and every `agent-preset/selected.data.agentPreset`, including inherited selections. Other preset ids and absent header presets remain unchanged; a selection without a string preset id is refused. The structural stage inserts an empty `system/message` immediately after the first `step/start`, then replaces that protected head before each changed `request/header` prompt, including clears. It removes `header.system` from every request header without moving any source event. Metadata-only logs gain no head.
Full restoration feeds decoded events through a fresh stage and validates the target artifact. Callers must not treat partial stage emissions as a successful restore: an error can occur at a later event or at `finish()`. The [format protocol](../session-format/README.md) owns stage scheduling and catalog error handling; [JSONL persistence](../session-persistence-jsonl/README.md) owns read preparation and immutable successor publication.
After structural insertion and reference remapping, canonicalization renames exact replacement objects on both original and synthetic envelopes from `{ op: 'replace', start, end }` to `{ op: 'replace', startSeq, endSeq }` and omits `tools: []` and `adapterDefaults: {}` from request headers. This final canonicalization preserves its input event count, coordinates, timestamps, order, and inherited cut; the preceding structural stage changes event count, sequences, audited references, and cuts. Whitespace-only system content, `config.stop: []`, and nested header/source/data extras remain intact.
-----
All four surface types (`system/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp`; only assistant messages forbid `sourceEventSeqs`. Replacement endpoints name an inclusive span in current surface order, not numeric sequence order. Known log-only events forbid both surface metadata fields. Native unknown or obsolete ignorable envelopes remain opaque. A `tool/result` carrying `data.error` requires its tool-result block to have `isError: true`; failed results may omit error identity. Missing placement, extra replacement keys, aliases, and contradictory outcomes are refused, never repaired. Native V3 rejects every `header.system` and noncanonical empty header optionals.
<a id="v2-to-v3-specification"></a>
## V2-to-V3 specification
Use the [public exports](src/index.ts) through catalog assembly: the migration, source and target codecs, target header validator, and target restorer. Event-local checks do not define a complete schema for plugin-owned payloads.
The complete edge is not an identity conversion. It preserves the relative order and timestamps of source events and the meaning of each historical request, but inserted system events change event count, dense sequence positions, local references, and inherited cuts. PTC/preset translation and final envelope canonicalization add no events. Only the named fields below change; preservation applies to admitted input, not arbitrary unaudited extensions.
The stage maps `tool/code-dispatch-start` and `tool/code-dispatch` to `tool/ptc-dispatch-start` and `tool/ptc-dispatch`. It replaces the exact `tools-code-mode` plugin attribution with `tools-ptc` in user messages, inbox insertions, and title-request messages, without rewriting IDs, tool arguments, or content. Native V3 rejects required predecessor PTC tags, including after recoverable row corruption; ignorable predecessor tags remain opaque and cannot satisfy current PTC relationships. Reserved V3 PTC tags in V2 source input are refused even when ignorable.
<a id="header-and-presets"></a>
### Header and preset references
The logical header changes `version: 2` to `version: 3`. It retains `id`, `createdAt`, `isSeeded`, `delegationDepth`, and admitted optional `cwd`, `parentSession`, and `origin`. The exact preset id `code` becomes `ptc` in `header.agentPreset` and every `agent-preset/selected.data.agentPreset`, including inherited and local selections. Other strings and an absent header preset remain unchanged. Selection payloads require a string preset id and reject unaudited members.
This conversion does not inspect installed presets or rewrite other occurrences of `code`. Released V0/V1 data receives it only after the frozen preceding edges reach V2. Native V3 custom preset ids are not renamed, and `settings.yaml` is outside this package.
<a id="system-head"></a>
### System head and message identities
The first `step/start` is followed immediately by an empty `system/message` append, even if the step aborts without a request. Later steps do not create another head. A log with no step and no surface receives no head or invented request.
At every `request/header`, absent `data.header.system` means the empty prompt; otherwise its string is compared exactly with the current prompt. A change inserts a system message immediately before that request header, replacing exactly the current protected head and citing it in `sourceEventSeqs`. An unchanged prompt inserts nothing. Empty strings and absent fields clear an earlier prompt; whitespace-only strings remain nonempty text. Every request header loses `data.header.system`, regardless of whether a replacement was needed.
Synthetic messages carry the open step's `turn` and `step`, the anchor event's `time`, role `system`, and source `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`. Empty prompts use `content: []`; other prompts use one text block containing the exact string. The first append has no provenance; each replacement uses the preceding head's target sequence for both endpoints and its sole source reference. Empty heads retain protection but produce no model message.
Each synthetic id is `v2-to-v3-system-` followed by the hexadecimal SHA-256 of `JSON.stringify(['session-format-v2-to-v3', sourceHeader.id, anchor.seq, anchor.type])`. The anchor is the source `step/start` for initial creation or the changed `request/header` for replacement. Collisions with generated or source message ids are refused in either encounter order, including ids in inbox insertions and title-request messages. Existing message ids never change. In particular, a `TOOL_NOT_STARTED` repair id retains its canonical historical `interrupted-tool-result-<callId>-<integer>` suffix; that suffix is not a target sequence coordinate.
<a id="sequence-references"></a>
### Sequence references and inheritance
Source events must be dense from zero. Each original event receives its target position after any preceding insertion. The [reference mapper](src/references.ts) changes only these same-artifact references; every referenced source position must name an earlier event with an established mapping:
| Owner | Fields remapped |
|---|---|
| Surface envelope | `sourceEventSeqs[]`; `surfaceOp.start/end` before their canonical rename |
| `command/done.data` | `sourceEventSeq` when present |
| `compaction/summary.data` and `compaction/prune.data` | `shadowedRange.start/end` and `shadowedSeqs[]` |
| `session/title.data` and `session/title-llm-request.data` | `messageSeqs[]` |
There is no recursive numeric-field rewrite. Delivery `throughSeq` and `sessionFormatVersion`, session-reference `capturedThroughSeq` and `capturedFormatVersion`, workflow-local `seq`, stream block indices, turn/step numbers, inbox indices, token/byte counts, and all ids keep their source values. Embedded assistant streams, model replay state, tool arguments/results, title-request input text and `data.system` retain their recorded meaning. Compaction payload endpoints keep the names `start/end`; only envelope replacement endpoints are renamed.
For a seeded Session, the last `session/end-seed` with `data.inherited: true` identifies the source cut. Its source sequence is the inherited event count, excluding that marker; its mapped target sequence is the target cut. Synthetic events before it are inherited, and later ones are local. An untagged marker does not establish the cut. A supplied `sourceInheritedEventCount` must agree; a seeded log without a marker and an unseeded log with one are refused. Unseeded stages expose `headerInheritedEventCount: 0`; seeded stages leave it unknown until `finish()` derives the exact cut. This also supports V0/V1 chains whose preceding stage changes event count and cannot supply the cut before EOF.
<a id="ptc-vocabulary"></a>
### PTC vocabulary
The exact event tags `tool/code-dispatch-start` and `tool/code-dispatch` become `tool/ptc-dispatch-start` and `tool/ptc-dispatch`. Their payloads retain their values. Plugin attribution changes from exactly `tools-code-mode` to `tools-ptc` only when `source.kind === 'plugin'` in these three slots:
- `user/message.data.source.plugin`
- `agent/inbox/spliced.data.inserted[].source.plugin`
- `session/title-llm-request.data.messages[].source.plugin`
Similar plugin names, other source kinds, arbitrary text, nested JSON, and historical ids including `:code:` remain unchanged. This does not rename `run_code` or its `code` argument. V2 source events already using either reserved V3 PTC tag are refused even when ignorable; an opaque source extension must not acquire current lifecycle meaning through migration.
<a id="canonical-envelopes"></a>
### Canonical envelopes and tool errors
After structural insertion and reference remapping, canonicalization converts exact envelope replacements `{ op: 'replace', start, end }` to `{ op: 'replace', startSeq, endSeq }` on original and synthetic events. It omits exactly `tools: []` and `adapterDefaults: {}` from `request/header.data.header`. This final operation preserves its input event count, coordinates, timestamps, order, and inherited cut; it neither remaps twice nor normalizes unrelated empty values such as `config.stop: []`.
All four V3 surface types (`system/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp`. Assistant messages alone forbid `sourceEventSeqs`; for the others, a supplied list must be nonempty, unique, and refer only to earlier events. Known log-only events allow neither surface metadata field. Replacements allow no aliases or extra keys. Their endpoints identify an inclusive span in current surface order, not numeric sequence order; restoration checks live membership, endpoint order, and complete provenance coverage.
Source surface events already require placement; migration does not invent missing append markers. A `tool/result` with `data.error` requires its single tool-result block to carry `isError: true`. Failed results may omit structured error identity. Contradictory outcomes are refused, never repaired by adding `isError` or deleting diagnostics. Ordinary tool and PTC lifecycle relationships still require validation after these event-local checks.
<a id="delivery-guards"></a>
### Delivery guards
A V2 `session-log-deepseek/delivery-accepted` with `data.sessionFormatVersion === 3` is refused, not promoted into a V3 upload watermark. Markers for other generations retain their payloads, including an absent generation and future non-target generations. A V2-generation marker must have a valid earlier `throughSeq`; if it names a different Session, it is permitted only in the inherited prefix of a Session with `parentSession`. A foreign local marker or one without parent metadata is refused. The marker's envelope sequence changes normally; its captured acceptance coordinates do not.
<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.
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.
-----
<a id="native-v3-admission"></a>
## Native V3 admission
Input already marked V3 does not run V2-to-V3. Native catalog reads with `validation: 'transformed'` apply codec checks only and skip artifact restoration; full relationships, open-step ownership, protected-head operations, and vocabulary checks require `restoreReleasedV3Artifact` or catalog `validation: 'current'`. The following rules distinguish those restoration checks from codec admission; they are not additional historical transformations:
- Native V3 admits in-history system appends, non-head system replacements, and compaction of non-head system nodes. System messages require valid payloads and matching open-step ownership. The first surface system head can be replaced only by a system message covering exactly that head; ordinary replacements and compaction cannot consume it. Migration itself produces only the initial head and head replacements, not route-dependent in-history updates.
- Native V3 rejects every `request/header.data.header.system`, even empty or malformed, and rejects noncanonical replacement spellings and the two empty header optionals. It preserves whitespace content, empty stop lists, and admitted nested header/source/data extensions. That extension admission does not widen the V2 source audit or the exact logical Session header fields.
- Required predecessor PTC tags are refused even if installed. Obsolete or unknown ignorable events remain opaque, including their logical metadata, and cannot satisfy current PTC relationships. Installed ordinary event additions are admitted as log-only envelopes; unknown required types are refused by vocabulary-aware restoration. The physical codec still enforces released framing and provenance encoding.
- V3 event-local checks run before encoding and after decoding. Raw retired-system-header, malformed-system-payload, and required predecessor-PTC refusal run before recoverable suppression, including after corrupt rows. Strict reads reject canonical errors immediately. Recoverable canonical decoding withholds the first invalid event and its suffix; a later `turn/end` establishes a commit and rejects that suffix. Only accepted inherited markers count; a seeded accepted prefix without one is refused. Unclassified event metadata is deferred to vocabulary-aware restoration rather than discarded as canonical corruption, so it cannot hide an unknown required type.
-----
@@ -53,11 +141,9 @@ The stage maps `tool/code-dispatch-start` and `tool/code-dispatch` to `tool/ptc-
<details>
<summary>Implementation internals — click to expand</summary>
The [stage](src/migration.ts) emits synchronously, retains coordinate mappings, message identity sets, and current prompt/lifecycle state, and expands compact runs incrementally. It derives the target inherited cut from the last inherited end-seed marker, including when an upstream stage cannot supply a cut before EOF. The [reference mapper](src/references.ts) remaps local envelope provenance/replacement ranges, command source references, compaction ranges/lists, and title message lists. Delivery watermarks, session-reference capture coordinates, workflow counters, embedded model input, and message IDs retain their original meaning. V2 delivery markers claiming V3 acceptance are rejected.
The [stage](src/migration.ts) owns synchronous per-artifact sequence maps, message identity sets, and prompt/lifecycle state. Compact runs expand incrementally. The [codec](src/codec.ts) reuses frozen V2 framing; the [restorer](src/validation.ts) validates V3 structure before giving frozen ordinary relationship validation a private system/PTC/repair-id and endpoint view. That view retains the actual target generation for delivery checks and never escapes: restoration returns the original V3 artifact and identities. Frozen V0-to-V1 and V1-to-V2 semantics remain unchanged. No runtime invariant companion is published because this library owns no independently observable registrations or state replicas.
The [validator](src/validation.ts) checks canonical envelopes, system payloads, open-step ownership, and protected-head operations independently. Native V3 also admits in-history system appends, non-head replacements, and compaction of non-head system nodes. Frozen ordinary relationship validation receives a private system/PTC/repair view composed with the canonical-endpoint view; the result retains the original V3 events, IDs, and actual target generation. Generated repair-ID suffixes remain historical identities, not current sequence coordinates. Frozen v0-to-v1 and v1-to-v2 semantics remain unchanged.
The [codec](src/codec.ts) shares frozen V2 physical framing and provenance encoding and validates V3 event-local rules before encoding and after decoding. Retired system-header, malformed system-payload, and required predecessor-PTC refusal precede recoverable decoding. Unclassified event metadata stays uninterpreted until vocabulary-aware restoration, which refuses unknown required types and validates installed log-only envelopes; the codec cannot discard these required records as malformed canonical tails. Strict reads reject canonical format errors immediately; committed-prefix recovery withholds an invalid suffix and rejects it if a later `turn/end` establishes a commit. [Admission tests](tests/admission.spec.ts) cover malformed durable payloads, repair identities, protected-head violations, and compaction reference remapping. No runtime invariant companion is published because this library owns no independently observable runtime registrations or state replicas.
[Combined catalog tests](tests/combined-migration.spec.ts) exercise transformation composition and native reopen; [migration tests](tests/migration.spec.ts) and [canonical tests](tests/canonical-envelopes.spec.ts) pin preservation and refusal. [Persistence integration](../session-persistence-jsonl/tests/v2-ptc-migration.spec.ts) owns publication evidence. The [released-format decision](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns the rationale for testing adjacent composition separately from native admission.
</details>
@@ -66,9 +152,9 @@ The [codec](src/codec.ts) shares frozen V2 physical framing and provenance encod
<a id="further-exploration"></a>
## Further Exploration
- [Released v1 to v2](../session-format-v1-to-v2/README.md) — frozen source codec and event validation.
- [Session format protocol](../session-format/README.md) — adjacent streaming stages.
- [Canonical V3 envelope decision](../../../.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md) — exact conversion and refusal rationale.
- [Released V1 to V2](../session-format-v1-to-v2/README.md) — frozen preceding conversion and source codec.
- [System-prompt surface decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md) — prompt ownership and protected-head rationale.
- [Canonical V3 envelope decision](../../../.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.md) — strict acceptance and validation ownership.
-----
@@ -79,25 +165,23 @@ The [codec](src/codec.ts) shares frozen V2 physical framing and provenance encod
#### What the model sees
`sessionFormatV2ToV3` preserves prompt text and ordinary messages at each historical request. Empty heads produce no model message. PTC plugin-source attribution uses `tools-ptc`; log-only dispatch events do not add model messages.
Each historical request retains its prompt text and ordinary message content. Empty system heads produce no model message. PTC attribution uses `tools-ptc`; dispatch events remain log-only.
#### Token effect
No message content is added or removed.
The edge adds no model-visible text; it moves the recorded prompt from the request header into the message history.
#### KV Cache effect
The stage does not change message content or model configuration.
The edge preserves historical request meaning and model configuration; it does not guarantee provider cache hits or byte-identical native V3 recordings.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **Historical preset ownership** — `code` in released V0/V1/V2 preset references denotes the legacy built-in preset. Those logs cannot distinguish a custom preset with the same id; native V3 references are not reinterpreted. This library does not migrate `settings.yaml`.
- **No file publication** — persistence owns immutable successor publication; this package never overwrites released generations.
- **Chronology-preserving inputs** — a surface event before the first step, or a changed prompt outside an open step, is refused with `SessionFormatUnsupportedMigrationError`; moving events or inventing out-of-step system messages would violate reconstruction.
- **Audited migration vocabulary** — V2 events, including log-only Assistant attempts, and the installed message-feedback additions are classified explicitly. Agent relay attribution and file attachment metadata are preserved without interpreting their identifiers or byte counts as sequence references. Unknown events, even ignorable ones, and unknown message-source or content kinds are refused during migration because sequence dependencies cannot be inferred. Native equal-version reads retain ordinary ignorable-event admission and nested extensions; retired `header.system` and required predecessor PTC tags are prohibited.
- **No semantic repair** — invalid released records and contradictory tool outcomes refuse restoration; the converter never supplies missing placement on source events or rewrites unrelated payloads.
- **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.
<a id="dev-note"></a>
### Dev Note
@@ -1,5 +1,5 @@
---
description: "将已发布v2 系统提示恢复为受保护的 v3 消息并规范信封保留历史请求含义。"
description: "完整V2 到 V3 Session 转换:系统头节点、经过审计的引用、PTC 与预设名称、规范信封保留与拒绝规则。"
kind: "package-library"
---
@@ -9,11 +9,20 @@ kind: "package-library"
## 概述
本库将已发布的 v2 系统提示恢复为受保护的 v3 消息,规范化事件信封,并转换持久化 PTC 词汇。它保留历史请求含义、源事件时序、时间戳、消息身份与继承归属,同时重映射经过审计的序列引用。持久化通过静态 Session 格式目录使用本库本库不发布或修改持久化文件。
将受支持的已发布 V2 Session 恢复为 V3,同时保留历史请求含义。本页是这条相邻迁移边的单一规范真源:先说明转换、保留与拒绝的内容,再单独说明原生 V3 准入。本库将系统提示词提升为消息,重映射本地事件引用,转换 PTC 与预设名称,并规范化信封。持久化通过静态目录使用本库本库不读取或发布文件。
## 目录
- [使用本包](#use-this-package)
- [V2 到 V3 规范](#v2-to-v3-specification)
- [头部与预设引用](#header-and-presets)
- [系统头节点与消息身份](#system-head)
- [序列引用与继承](#sequence-references)
- [PTC 词汇](#ptc-vocabulary)
- [规范信封与工具错误](#canonical-envelopes)
- [投递保护](#delivery-guards)
- [源审计与拒绝](#source-audit)
- [原生 V3 准入](#native-v3-admission)
- [理解实现](#understand-the-implementation)
- [深入探索](#further-exploration)
- [模型体验](#model-experience)
@@ -27,23 +36,102 @@ kind: "package-library"
### 使用场景
恢复时请使用[目录](../session-format-catalog/README.zh.md)。直接导入用于目录组装和测试;本库不挂载到 Cordis 组合中
使用[目录](../session-format-catalog/README.zh.md)恢复 Session。直接导入用于目录组装和测试;本库没有 Cordis 挂载配置。[公共导出](src/index.ts)提供迁移声明、已发布 V2 源编解码器、V3 目标编解码器、目标头校验器和目标恢复器
### 入口
仅头部操作不会转换或校验事件正文:
```text
const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
```
头部版本变为 3。`header.agentPreset` 和每条 `agent-preset/selected.data.agentPreset` 中精确匹配的旧预设标识 `code` 变为 `ptc`,包括继承的选择事件。其他预设标识以及缺失的头部预设保持不变;选择事件缺少字符串预设标识时会被拒绝。结构阶段在首个 `step/start` 后立即插入空 `system/message`,随后在每次提示发生变化的 `request/header` 前替换受保护的头节点,包括清空提示。每个请求头的 `header.system` 均被移除,不移动任何源事件。仅含元数据的日志不添加头节点
完整恢复将解码后的事件送入新的阶段,并校验目标产物。调用方不得将阶段的部分输出视为成功恢复:错误可能出现在后续事件或 `finish()`。[格式协议](../session-format/README.zh.md)负责阶段调度与目录错误处理;[JSONL 持久化](../session-persistence-jsonl/README.zh.md)负责读取准备和不可变后继代的发布
结构插入和引用重映射完成后,规范化将原始与合成信封上的精确替换对象从 `{ op: 'replace', start, end }` 重命名为 `{ op: 'replace', startSeq, endSeq }`,并省略请求头中的 `tools: []``adapterDefaults: {}`。最终规范化保留其输入事件数、坐标、时间戳、顺序与继承切点;此前的结构阶段会改变事件数、序列、经过审计的引用与切点。仅含空白的系统内容、`config.stop: []` 与嵌套 header/source/data 扩展保持原样。
-----
四种 surface 类型(`system/message``user/message``assistant/message``tool/result`)都要求 `surfaceOp`;只有 assistant 消息禁止 `sourceEventSeqs`。替换端点按当前 surface 顺序而非数值序号顺序标识闭区间。已知仅日志事件禁止两个 surface 元数据字段。原生未知或已退役的可忽略信封保持不透明。携带 `data.error``tool/result` 要求其工具结果块带有 `isError: true`;失败结果可以省略错误身份。缺失位置、额外替换键、别名与矛盾结果会被拒绝,绝不修复。原生 V3 拒绝任何 `header.system` 及非规范的空请求头可选字段。
<a id="v2-to-v3-specification"></a>
## V2 到 V3 规范
通过目录组装使用[公共导出](src/index.ts):迁移、源与目标编解码器、目标头校验器及目标恢复器。事件本地检查不为插件自有载荷定义完整 schema
整条迁移边不是恒等转换。它保留源事件的相对顺序、时间戳和每个历史请求的含义,但插入的系统事件会改变事件数、稠密序列位置、本地引用和继承切点。PTC/预设转换及最终信封规范化不添加事件。只有下文列出的字段发生变化;保留承诺适用于已接纳的输入,而非任意未经审计的扩展
阶段将 `tool/code-dispatch-start``tool/code-dispatch` 映射为 `tool/ptc-dispatch-start``tool/ptc-dispatch`。它在用户消息、收件箱插入消息与标题请求消息中,将精确匹配的 `tools-code-mode` 插件归属替换为 `tools-ptc`,不改写 ID、工具参数或内容。原生 V3 拒绝必需的前代 PTC 标签,包括出现在可恢复行损坏之后的标签;可忽略的前代标签保持不透明,不能满足当前 PTC 关系。V2 源输入中的 V3 保留 PTC 标签即使可忽略也会被拒绝。
<a id="header-and-presets"></a>
### 头部与预设引用
逻辑头部将 `version: 2` 改为 `version: 3`。它保留 `id``createdAt``isSeeded``delegationDepth`,以及已接纳的可选字段 `cwd``parentSession``origin`。在 `header.agentPreset` 和每条 `agent-preset/selected.data.agentPreset` 中,精确匹配的预设标识 `code` 变为 `ptc`,包括继承与本地选择。其他字符串和缺失的头部预设保持不变。选择载荷要求字符串预设标识,并拒绝未经审计的成员。
此转换不检查已安装预设,也不改写其他位置的 `code`。已发布 V0/V1 数据仅在经过冻结的前代迁移边到达 V2 后接受此转换。原生 V3 自定义预设标识不被重命名,`settings.yaml` 不属于本包范围。
<a id="system-head"></a>
### 系统头节点与消息身份
首个 `step/start` 后立即追加空 `system/message`,即使该步骤在发出请求前中止。后续步骤不再创建头节点。没有步骤也没有 surface 的日志不会获得头节点或虚构请求。
在每条 `request/header` 处,缺失的 `data.header.system` 表示空提示词;否则,其字符串与当前提示词进行精确比较。发生变化时,在该请求头之前立即插入系统消息,恰好替换当前受保护的头节点,并在 `sourceEventSeqs` 中引用它。提示词不变时不插入消息。空字符串和缺失字段会清空先前的提示词;仅含空白的字符串仍为非空文本。每个请求头都会移除 `data.header.system`,无论是否需要替换。
合成消息携带开放步骤的 `turn``step`、锚点事件的 `time`、角色 `system`,以及来源 `{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }`。空提示词使用 `content: []`;其他提示词使用包含精确字符串的单个文本块。首次追加没有溯源;每次替换的两个端点及唯一源引用都使用前一头节点的目标序号。空头节点保持受保护,但不产生模型消息。
每个合成标识由 `v2-to-v3-system-` 加上 `JSON.stringify(['session-format-v2-to-v3', sourceHeader.id, anchor.seq, anchor.type])` 的十六进制 SHA-256 构成。首次创建的锚点是源 `step/start`,替换的锚点是提示词变化的 `request/header`。与已生成或源消息标识的冲突均被拒绝,不受遇到顺序影响;检查范围包括收件箱插入消息与标题请求消息中的标识。现有消息标识绝不改变。特别是,`TOOL_NOT_STARTED` 修复标识保留规范的历史 `interrupted-tool-result-<callId>-<integer>` 后缀;该后缀不是目标序列坐标。
<a id="sequence-references"></a>
### 序列引用与继承
源事件必须从零开始稠密排列。每个原始事件在其前面的插入完成后获得目标位置。[引用映射器](src/references.ts)仅修改以下同产物引用;每个被引用的源位置必须指向已建立映射的更早事件:
| 所有者 | 重映射字段 |
|---|---|
| Surface 信封 | `sourceEventSeqs[]`;规范重命名前的 `surfaceOp.start/end` |
| `command/done.data` | 存在时的 `sourceEventSeq` |
| `compaction/summary.data``compaction/prune.data` | `shadowedRange.start/end``shadowedSeqs[]` |
| `session/title.data``session/title-llm-request.data` | `messageSeqs[]` |
不存在递归数值字段改写。投递的 `throughSeq``sessionFormatVersion`、会话引用的 `capturedThroughSeq``capturedFormatVersion`、工作流本地 `seq`、流块索引、轮次/步骤编号、收件箱索引、token/字节计数以及所有标识都保留源值。内嵌 assistant 流、模型回放状态、工具参数/结果、标题请求输入文本及 `data.system` 保留已记录的含义。压缩(compaction)载荷端点保持 `start/end` 名称;仅信封替换端点被重命名。
对于有种子的 Session,最后一条带有 `data.inherited: true``session/end-seed` 标识源切点。其源序号等于继承事件数,不含该标记;其映射后的目标序号即目标切点。此前的合成事件属于继承部分,此后的属于本地部分。未标记继承的结束标记不建立切点。若提供 `sourceInheritedEventCount`,则必须一致;有种子但没有标记的日志,以及无种子却有继承标记的日志都会被拒绝。无种子阶段公开 `headerInheritedEventCount: 0`;有种子阶段保持未知,直到 `finish()` 推导精确切点。这也支持前一阶段改变事件数、无法在 EOF 前提供切点的 V0/V1 迁移链。
<a id="ptc-vocabulary"></a>
### PTC 词汇
精确的事件标签 `tool/code-dispatch-start``tool/code-dispatch` 变为 `tool/ptc-dispatch-start``tool/ptc-dispatch`。其载荷值保持不变。仅在以下三个位置的 `source.kind === 'plugin'` 时,精确匹配的插件归属 `tools-code-mode` 才变为 `tools-ptc`
- `user/message.data.source.plugin`
- `agent/inbox/spliced.data.inserted[].source.plugin`
- `session/title-llm-request.data.messages[].source.plugin`
相似插件名、其他来源种类、任意文本、嵌套 JSON 及包含 `:code:` 的历史标识保持不变。此转换不重命名 `run_code` 或其 `code` 参数。已使用任一 V3 保留 PTC 标签的 V2 源事件即使可忽略也会被拒绝;不透明源扩展不得通过迁移获得当前生命周期含义。
<a id="canonical-envelopes"></a>
### 规范信封与工具错误
结构插入和引用重映射完成后,规范化将原始与合成事件上的精确信封替换对象 `{ op: 'replace', start, end }` 转为 `{ op: 'replace', startSeq, endSeq }`。它仅从 `request/header.data.header` 中省略精确的 `tools: []``adapterDefaults: {}`。这个最终操作保留其输入事件数、坐标、时间戳、顺序与继承切点;既不重复映射,也不规范化 `config.stop: []` 等无关空值。
四种 V3 surface 类型(`system/message``user/message``assistant/message``tool/result`)都要求 `surfaceOp`。仅 assistant 消息禁止 `sourceEventSeqs`;其他类型提供的列表必须非空、唯一,且仅引用更早的事件。已知仅日志事件不允许两个 surface 元数据字段。替换不允许别名或额外键。端点按当前 surface 顺序而非数值序号顺序标识闭区间;恢复会检查存活成员、端点顺序与完整溯源覆盖。
源 surface 事件本就要求位置标记;迁移不虚构缺失的追加标记。带有 `data.error``tool/result` 要求其唯一工具结果块携带 `isError: true`。失败结果可以省略结构化错误身份。矛盾结果会被拒绝,绝不通过添加 `isError` 或删除诊断来修复。普通工具与 PTC 生命周期关系仍须在这些事件本地检查后验证。
<a id="delivery-guards"></a>
### 投递保护
V2 `session-log-deepseek/delivery-accepted` 若携带 `data.sessionFormatVersion === 3`,就会被拒绝,而非提升为 V3 上传水位。其他代的标记保留其载荷,包括缺失代次和非目标的未来代次。V2 代标记必须具有有效且更早的 `throughSeq`;若它指向另一个 Session,则仅允许出现在具有 `parentSession` 的 Session 的继承前缀中。本地的外部会话标记或没有父会话元数据的外部会话标记会被拒绝。标记的信封序号正常变化;其捕获的接收坐标不变。
<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 审计。
首个步骤前的 surface 事件、开放步骤外的提示词变化或生成标识冲突,会抛出 `SessionFormatUnsupportedMigrationError`,而非移动事件或虚构归属。源字段格式错误、缺失位置、无效引用、不一致切点、投递违规与矛盾工具结果,会在直接阶段或目标校验器中抛出格式错误。目录将迁移阶段和转换后目标校验失败报告为类型化的不支持迁移;物理解码失败仍按所选恢复策略归类为损坏。本迁移边不修复源或目标,不回退代次,也不改写文件。
-----
<a id="native-v3-admission"></a>
## 原生 V3 准入
已标记为 V3 的输入不运行 V2 到 V3 迁移。使用 `validation: 'transformed'` 的原生目录读取仅执行编解码器检查,跳过产物恢复;完整关系、开放步骤归属、受保护头节点操作与词汇检查需要 `restoreReleasedV3Artifact` 或目录的 `validation: 'current'`。以下规则区分这些恢复检查与编解码器准入;它们不是额外的历史转换:
- 原生 V3 接纳历史内系统消息追加、非头系统节点替换和非头系统节点压缩。系统消息要求有效载荷及匹配的开放步骤归属。首个 surface 系统头节点只能被恰好覆盖该头节点的系统消息替换;普通替换和压缩不能消耗它。迁移本身只产生初始头节点与头节点替换,不产生依赖路由的历史内更新。
- 原生 V3 拒绝任何 `request/header.data.header.system`,包括空值或格式错误值,并拒绝非规范替换拼写及两个空请求头可选字段。它保留空白内容、空停止列表和已接纳的嵌套 header/source/data 扩展。此扩展准入不会扩大 V2 源审计或精确的逻辑 Session 头字段范围。
- 必需的前代 PTC 标签即使已安装也会被拒绝。已退役或未知的可忽略事件(包括其逻辑元数据)保持不透明,且不能满足当前 PTC 关系。已安装的普通事件新增项作为仅日志信封接纳;未知必需类型由识别词汇的恢复阶段拒绝。物理编解码器仍执行已发布的分帧与溯源编码规则。
- V3 事件本地检查在编码前及解码后执行。原始行的已退役系统头字段、畸形系统载荷和必需前代 PTC 的拒绝先于可恢复抑制执行,包括损坏行之后。严格读取立即拒绝规范错误。可恢复的规范解码不产出首个无效事件及其后缀;后续 `turn/end` 建立提交事实并拒绝该后缀。只有已接纳的继承标记计数;有种子的已接纳前缀若没有标记则被拒绝。未分类事件元数据会延迟到识别词汇的恢复阶段,而不是作为规范损坏丢弃,因此不能隐藏未知必需类型。
-----
@@ -53,11 +141,9 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
<details>
<summary>实现细节 — 点击展开</summary>
[阶段](src/migration.ts)同步输出,保留坐标映射、消息身份集合与当前提示、生命周期状态,并增量展开紧凑事件段。它从最后一个继承 end-seed 标记推导目标继承切点,包括上游阶段在 EOF 前无法提供切点的情况。[引用映射器](src/references.ts)重映射本地信封溯源与替换范围、命令源引用、压缩范围与列表,以及标题消息列表。投递水位、会话引用捕获坐标、工作流计数器、嵌入的模型输入与消息 ID 保留原有含义。声称已获 V3 接收的 V2 投递标记会被拒绝
[阶段](src/migration.ts)拥有每份产物独立的同步序列映射、消息身份集合和提示词/生命周期状态。紧凑事件段增量展开。[编解码器](src/codec.ts)复用冻结的 V2 分帧;[恢复器](src/validation.ts)先校验 V3 结构,再向冻结的普通关系校验提供私有 system/PTC/修复标识与端点视图。该视图为投递检查保留实际目标代次,且绝不对外返回:恢复返回原始 V3 产物与身份。冻结的 V0 到 V1 和 V1 到 V2 语义保持不变。本库不拥有可独立观察的注册或状态副本,因此不发布运行时不变量伴随入口
[校验器](src/validation.ts)独立检查规范信封、系统载荷、开放步骤归属与受保护的头节点操作。原生 V3 也接受历史内系统消息追加、非头节点替换,以及非头系统节点的压缩。冻结的普通关系校验接收由私有 system/PTC/repair 视图与规范端点视图组合而成的输入;结果保留原始 V3 事件、ID 与实际目标代次。生成的修复 ID 后缀仍是历史身份,而非当前序列坐标。冻结的 v0-to-v1 与 v1-to-v2 语义保持不变
[编解码器](src/codec.ts)共享冻结的 V2 物理分帧与溯源编码,并在编码前及解码后验证 V3 事件本地规则。已退役系统头字段、畸形系统载荷与必需前代 PTC 的拒绝先于可恢复解码。未分类事件的元数据在识别事件词汇的恢复阶段之前保持不解释;该阶段拒绝未知必需类型并校验已安装的仅日志信封,编解码器不能把这些必需记录当作畸形规范尾部丢弃。严格读取立即拒绝规范格式错误;已提交前缀恢复不产出无效后缀,若后续 `turn/end` 证明该后缀已提交,则会拒绝它。[准入测试](tests/admission.spec.ts)覆盖畸形持久化载荷、修复身份、受保护头节点违规与压缩引用重映射。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。
[组合目录测试](tests/combined-migration.spec.ts)验证转换组合与原生重新打开;[迁移测试](tests/migration.spec.ts)和[规范测试](tests/canonical-envelopes.spec.ts)固定保留与拒绝规则。[持久化集成](../session-persistence-jsonl/tests/v2-ptc-migration.spec.ts)负责发布证据。[已发布格式决策](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)负责将相邻组合测试与原生准入测试分开的依据
</details>
@@ -66,9 +152,9 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
<a id="further-exploration"></a>
## 深入探索
- [已发布 v1 到 v2](../session-format-v1-to-v2/README.zh.md) — 冻结的源编解码器和事件校验
- [Session 格式协议](../session-format/README.zh.md) — 相邻流式阶段
- [规范 V3 信封决策](../../../.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md) — 精确转换与拒绝依据
- [已发布 V1 到 V2](../session-format-v1-to-v2/README.zh.md) — 冻结的前代转换与源编解码器。
- [系统提示词 surface 决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md) — 提示词归属与头节点保护依据
- [规范 V3 信封决策](../../../.agents/notes/implemented/architecture/2026-09-06-v3-canonical-session-envelopes.zh.md) — 严格准入与校验归属
-----
@@ -79,25 +165,23 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
#### 模型看到什么
`sessionFormatV2ToV3`每个历史请求保留提示文本与普通消息。空头节点不产生模型消息。PTC 插件来源使用 `tools-ptc`仅日志的分发事件不会添加模型消息
每个历史请求保留提示文本与普通消息内容。空系统头节点不产生模型消息。PTC 归属使用 `tools-ptc`分发事件仍仅写日志
#### Token 影响
不添加或删除消息内容
迁移边不添加模型可见文本;它将已记录的提示词从请求头移入消息历史
#### KV Cache 影响
阶段不改变消息内容或模型配置
迁移边保留历史请求含义与模型配置;它不保证提供方缓存命中,也不保证与原生 V3 录制字节相同
## 已知限制与后续工作
<a id="known-limitations-and-deferred-work"></a>
- **历史预设归属** — 已发布 V0/V1/V2 的预设引用中,`code` 表示旧内置预设。这些日志无法区分同名的自定义预设;原生 V3 引用不会被重新解释。本库不迁移 `settings.yaml`
- **不发布文件** — 持久化负责不可变后继代的发布;本包绝不覆盖已发布代
- **保持时序的输入** — 首个步骤前出现表面事件,或在开放步骤之外更改提示时,以 `SessionFormatUnsupportedMigrationError` 拒绝;移动事件或虚构步骤外的系统消息会破坏重建
- **经过审计的迁移词汇** — 显式分类 V2 事件(包括仅日志 Assistant 尝试)与已安装的消息反馈扩展。Agent 中继归属与文件附件元数据保持不变,其标识符与字节计数不被解释为序列引用。迁移拒绝未知事件(即使标为可忽略)及未知消息来源或内容种类,因为无法推断其序列依赖。原生同版本读取保留普通可忽略事件的准入规则及嵌套扩展,禁止已退役的 `header.system` 与必需的前代 PTC 标签。
- **不修复语义** — 无效已发布记录与矛盾工具结果会使恢复被拒绝;转换器绝不补充源事件缺失的位置,也不改写无关载荷。
- **历史预设歧义** — 已发布 `code` 引用无法区分与旧内置标识同名的自定义预设;[精确重命名](#header-and-presets)不依赖宿主
- **有范围的源审计** — [源拒绝](#source-audit)可能拒绝无法在保留时序的同时转换的历史,但已分类 Message 位置之外的嵌套内容不接受相同的种类审计。原生扩展支持不意味着迁移支持
- **不迁移文件或设置** — 本包绝不修改已提交代或 `settings.yaml`。持久化负责发布最终后继代;已有 V3 代不重新运行其入边
<a id="dev-note"></a>
### 开发备注
@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest'
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
import { sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
import { assertReleasedV3Header, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts'
const header = { version: 2, id: 'canonical:code:session', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const config = { provider: 'mock', model: 'mock', stop: [] }
const catalog = createSessionFormatCatalog({
currentVersion: 3,
codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec],
migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3],
currentEncoder: releasedV3SessionFormatCodec,
restoreCurrentHeader(value) { assertReleasedV3Header(value); return value },
restoreCurrent: value => restoreReleasedV3Artifact(value, new Set()),
restoreTransformedCurrent: value => restoreReleasedV3Artifact(value, new Set()),
})
function event(type: string, seq: number, data: SessionFormatEvent['data'], fields: SessionFormatJsonObject = {}): SessionFormatEvent {
return { type, seq, time: seq - 20, data, ...fields }
}
function restore(events: readonly SessionFormatEvent[], sourceHeader = header): SessionFormatArtifact {
const reader = catalog.createRestore({ type: 'session', ...sourceHeader }, { recovery: 'strict', validation: 'current' })
for (const row of events) reader.decodeRow(row)
return reader.finish()
}
function reopen(artifact: SessionFormatArtifact): SessionFormatArtifact {
const reader = catalog.createRestore(releasedV3SessionFormatCodec.encodeHeader(artifact.header, artifact.inheritedEventCount), {
recovery: 'strict', validation: 'current',
})
for (const row of artifact.events) reader.decodeRow(releasedV3SessionFormatCodec.encodeEvent(row))
return reader.finish()
}
const opening = [event('turn/start', 0, { turn: 1 }), event('step/start', 1, { turn: 1, step: 1 })]
const message = {
id: 'message:code:1', role: 'user', source: { kind: 'plugin', plugin: 'tools-code-mode' },
content: [{ type: 'text', text: 'tools-code-mode tool/code-dispatch code 图片' }],
}
const dispatch = {
rootCallId: 'root:code:1', parentCallId: 'root:code:1', subCallId: 'root:code:1:code:2',
name: 'run_code', arguments: { code: 'return 3', source: { kind: 'plugin', plugin: 'tools-code-mode' }, start: 4, end: 6 },
}
function seededHistory(): SessionFormatEvent[] {
return [
event('agent-preset/selected', 0, { agentPreset: 'code' }),
event('turn/start', 1, { turn: 1 }),
event('step/start', 2, { turn: 1, step: 1 }),
event('request/header', 3, { reason: 'initial', header: { config, system: 'seed prompt', tools: [], adapterDefaults: {} } }),
event('user/message', 4, message, { surfaceOp: 'append' }),
event('tool/code-dispatch-start', 5, dispatch),
event('tool/code-dispatch', 6, { ...dispatch, isError: true, content: message.content }),
event('step/end', 7, { turn: 1, step: 1 }),
event('turn/end', 8, { turn: 1, reason: { kind: 'completed' } }),
event('session-log-deepseek/delivery-accepted', 9, { sessionId: 'parent', throughSeq: 8, sessionFormatVersion: 2 }),
event('session/end-seed', 10, { inherited: true }),
event('agent-preset/selected', 11, { agentPreset: 'standard' }),
event('agent-preset/selected', 12, { agentPreset: 'code' }),
event('turn/start', 13, { turn: 2 }),
event('step/start', 14, { turn: 2, step: 1 }),
event('request/header', 15, { reason: 'change', header: { config, system: 'local prompt', tools: [], adapterDefaults: {} } }),
event('user/message', 16, { ...message, id: 'message:code:2' }, {
surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [4, 6],
}),
event('step/end', 17, { turn: 2, step: 1 }),
event('turn/end', 18, { turn: 2, reason: { kind: 'completed' } }),
]
}
const seededHeader = { ...header, isSeeded: true, parentSession: 'parent', agentPreset: 'code' }
describe('canonical preservation across migration and native reload', () => {
it('keeps empty stop arrays and nested tool schema values while omitting only empty header optionals', () => {
const tools = [{ name: 'run_code', description: '', parameters: {
type: 'object', properties: {}, required: [], tools: [], adapterDefaults: {}, system: '',
source: { kind: 'plugin', plugin: 'tools-code-mode' },
} }]
const first = event('request/header', 2, { reason: 'initial', header: { config, system: ' \n', tools, adapterDefaults: {} } })
const second = event('request/header', 3, { reason: 'series', header: { config, system: ' \n', tools: [], adapterDefaults: {} } })
const source = [...opening, first, second]
const before = JSON.stringify(source)
const target = restore(source)
expect(target.events.filter(row => row.type === 'request/header')).toEqual([
{ ...first, seq: 4, data: { reason: 'initial', header: { config, tools } } },
{ ...second, seq: 5, data: { reason: 'series', header: { config } } },
])
expect(target.events.filter(row => row.type === 'system/message').map(row =>
((row.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject)['content'],
)).toEqual([[], [{ type: 'text', text: ' \n' }]])
expect(reopen(target)).toEqual(target)
expect(JSON.stringify(source)).toBe(before)
})
it('preserves native code presets, attribution and nested canonical-looking extension values', () => {
const nativeHeader = { ...header, version: 3, agentPreset: 'code' }
const extension = { tools: [], adapterDefaults: {}, system: '', surfaceOp: { op: 'replace', start: 90, end: 99 }, agentPreset: 'code' }
const rows = [
event('agent-preset/selected', 0, { agentPreset: 'code', extension }),
event('turn/start', 1, { turn: 1 }),
event('step/start', 2, { turn: 1, step: 1 }),
event('user/message', 3, { ...message, source: { ...message.source, extension } }, { surfaceOp: 'append' }),
event('request/header', 4, { reason: 'initial', header: { config, extension } }),
]
const before = JSON.stringify({ header: nativeHeader, rows })
const target = restore(rows, nativeHeader)
expect(target).toEqual({ header: nativeHeader, inheritedEventCount: 0, events: rows })
expect(reopen(target)).toEqual(target)
expect(JSON.stringify({ header: nativeHeader, rows })).toBe(before)
})
it('composes inherited and local preset selections with shifted heads, PTC failure, replacement and historical delivery', () => {
const source = seededHistory()
const before = JSON.stringify({ header: seededHeader, source })
const target = restore(source, seededHeader)
const mappedSeqs = [0, 1, 2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21]
expect(target.header).toEqual({ ...seededHeader, version: 3, agentPreset: 'ptc' })
expect(target.inheritedEventCount).toBe(12)
expect(target.events.filter(row => row.type !== 'system/message')).toEqual(source.map((row, index) => ({
...row, seq: mappedSeqs[index],
...(row.type === 'agent-preset/selected' && (row.data as SessionFormatJsonObject)['agentPreset'] === 'code'
? { data: { agentPreset: 'ptc' } } : {}),
...(row.type === 'request/header' ? { data: { ...row.data as SessionFormatJsonObject, header: { config } } } : {}),
...(row.type === 'user/message' ? { data: { ...row.data as SessionFormatJsonObject, source: { kind: 'plugin', plugin: 'tools-ptc' } } } : {}),
...(row.type === 'tool/code-dispatch-start' ? { type: 'tool/ptc-dispatch-start' } : {}),
...(row.type === 'tool/code-dispatch' ? { type: 'tool/ptc-dispatch' } : {}),
...(row.seq === 16 ? { surfaceOp: { op: 'replace', startSeq: 6, endSeq: 6 }, sourceEventSeqs: [6, 8] } : {}),
})))
expect(target.events.filter(row => row.type === 'system/message').map(row => ({
seq: row.seq, time: row.time, surfaceOp: row['surfaceOp'], sourceEventSeqs: row['sourceEventSeqs'],
}))).toEqual([
{ seq: 3, time: -18, surfaceOp: 'append', sourceEventSeqs: undefined },
{ seq: 4, time: -17, surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3] },
{ seq: 17, time: -5, surfaceOp: { op: 'replace', startSeq: 4, endSeq: 4 }, sourceEventSeqs: [4] },
])
expect(target.events[12]).toEqual({ ...source[10], seq: 12 })
expect(reopen(target)).toEqual(target)
expect(JSON.stringify({ header: seededHeader, source })).toBe(before)
})
})
describe('canonical refusal in composed catalog paths', () => {
it.each(['tool/ptc-dispatch-start', 'tool/ptc-dispatch', 'agent-preset/selected', 'session-log-deepseek/delivery-accepted'])(
'does not treat ignorable known %s as an opaque envelope', (type) => {
const target = restore(seededHistory(), seededHeader)
const index = target.events.findLastIndex(row => row.type === type)
const original = target.events[index]!
for (const metadata of [{ surfaceOp: 'append' }, { sourceEventSeqs: [0] }]) {
const invalid = { ...original, ignorable: true, ...metadata }
expect(() => releasedV3SessionFormatCodec.encodeEvent(invalid)).toThrow(/unexpected field/)
expect(() => restoreReleasedV3Artifact({ ...target, events: target.events.with(index, invalid) }, new Set()))
.toThrow(/unexpected field/)
const reader = catalog.createRestore({ type: 'session', ...target.header }, { recovery: 'strict', validation: 'transformed' })
for (const row of target.events.slice(0, index)) reader.decodeRow(releasedV3SessionFormatCodec.encodeEvent(row))
expect(() => { reader.decodeRow(invalid) }).toThrow(/unexpected field/)
}
},
)
it.each(['current', 'transformed'] as const)('refuses target-generation delivery before and after the inherited cut under %s recovery', (validation) => {
for (const recovery of ['strict', 'recoverable'] as const) {
for (const inherited of [true, false]) {
const source = seededHistory()
const seq = inherited ? 9 : source.length
const marker = event('session-log-deepseek/delivery-accepted', seq, {
sessionId: inherited ? 'parent' : header.id, throughSeq: 8, sessionFormatVersion: 3,
}, { ignorable: true })
const rows = inherited ? source.with(seq, marker) : [...source, marker]
const before = JSON.stringify(rows)
const reader = catalog.createRestore({ type: 'session', ...seededHeader }, { recovery, validation })
expect(() => {
for (const row of rows) reader.decodeRow(row)
reader.finish()
}).toThrow('format v2 delivery marker claims target format v3')
expect(JSON.stringify(rows)).toBe(before)
}
}
})
it('refuses a local foreign V2 watermark even when an earlier inherited watermark has the same owner', () => {
const source = seededHistory()
const local = event('session-log-deepseek/delivery-accepted', source.length, {
sessionId: 'parent', throughSeq: 8, sessionFormatVersion: 2,
})
expect(() => restore([...source, local], seededHeader)).toThrow('current-generation delivery marker names the wrong Session')
expect(restore(source, seededHeader).events.find(row => row.type === 'session-log-deepseek/delivery-accepted')?.data)
.toEqual(source[9]?.data)
})
})
@@ -0,0 +1,217 @@
/** Structural promotion preserves each historical request and distinguishes local from captured coordinates. */
import { describe, expect, it } from 'vitest'
import { SessionFormatEventCollector, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts'
const header: SessionFormatHeader = {
version: 2, id: 'structural-regressions', createdAt: 1, isSeeded: false, delegationDepth: 0,
}
const config = { provider: 'mock', model: 'mock' }
const message = (id: string) => ({ id, role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: id }] })
const row = (type: string, data: SessionFormatEvent['data'], fields: SessionFormatJsonObject = {}): SessionFormatEvent => ({ type, seq: 0, time: 0, data, ...fields })
const user = (id: string) => row('user/message', message(id), { surfaceOp: 'append' })
const request = (system?: string) => row('request/header', { reason: 'initial', header: { config, ...(system === undefined ? {} : { system }) } })
const dense = (events: readonly SessionFormatEvent[]) => events.map((event, seq) => ({ ...event, seq, time: -100 + seq * 7 }))
const opening = (turn = 1, step = 1) => [row('turn/start', { turn }), row('step/start', { turn, step })]
const closing = (turn = 1, step = 1) => [row('step/end', { turn, step }), row('turn/end', { turn, reason: { kind: 'completed' } })]
const turn = (number: number, prompt: string) => [...opening(number), user(`user-${number}`), request(prompt), ...closing(number)]
function migrate(events: readonly SessionFormatEvent[], sourceHeader = header, sourceInheritedEventCount?: number): SessionFormatArtifact {
const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
const stage = sessionFormatV2ToV3.createStage({ sourceHeader, targetHeader, sourceInheritedEventCount, sourceKind: 'decoded' })
const collector = new SessionFormatEventCollector()
for (const event of events) stage.transformEvent(event, collector)
return restoreReleasedV3Artifact({
header: targetHeader, inheritedEventCount: stage.finish(collector), events: collector.values,
}, new Set())
}
function roundTrip(artifact: SessionFormatArtifact): SessionFormatArtifact {
const decoder = releasedV3SessionFormatCodec.createDecoder(releasedV3SessionFormatCodec.encodeHeader(artifact.header, artifact.inheritedEventCount), 'strict')
const collector = new SessionFormatEventCollector()
for (const event of artifact.events) decoder.decodeRow(releasedV3SessionFormatCodec.encodeEvent(event), collector)
return restoreReleasedV3Artifact({
header: decoder.header, inheritedEventCount: decoder.finish(collector), events: collector.values,
}, new Set())
}
function requestMessages(events: readonly SessionFormatEvent[]) {
const surface: SessionFormatEvent[] = []
const requests: unknown[] = []
for (const event of events) {
if (event['surfaceOp'] === 'append') surface.push(event)
else if (event['surfaceOp'] !== undefined) {
const operation = event['surfaceOp'] as SessionFormatJsonObject
const start = surface.findIndex(candidate => candidate.seq === operation['startSeq'])
const end = surface.findIndex(candidate => candidate.seq === operation['endSeq'])
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThanOrEqual(start)
surface.splice(start, end - start + 1, event)
}
if (event.type === 'request/header') {
requests.push(surface.flatMap((entry) => {
const data = entry.data as SessionFormatJsonObject
const value = (entry.type === 'user/message' ? data : data['message']) as SessionFormatJsonObject
return (value['content'] as readonly unknown[]).length === 0 ? [] : [{ role: value['role'], content: value['content'] }]
}))
}
}
return requests
}
const visible = (role: string, text: string) => ({ role, content: [{ type: 'text', text }] })
const systems = (artifact: SessionFormatArtifact) => artifact.events.filter(event => event.type === 'system/message')
const systemId = (event: SessionFormatEvent) => ((event.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject)['id']
function startedToolCall(): SessionFormatEvent[] {
return [
row('assistant/message', { turn: 1, step: 1, stream: [], message: {
id: 'tool-request', role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' },
content: [{ type: 'tool-call', id: 'call', name: 'read', arguments: '{}' }],
} }, { surfaceOp: 'append' }),
row('tool/call', { turn: 1, step: 1, callId: 'call', name: 'read', arguments: '{}' }),
]
}
const carriers = [
['user', (value: SessionFormatJsonObject) => row('user/message', value, { surfaceOp: 'append' })],
['assistant', (value: SessionFormatJsonObject) => row('assistant/message', { turn: 1, step: 1, stream: [], message: { ...value, role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' } } }, { surfaceOp: 'append' })],
['tool', (value: SessionFormatJsonObject) => row('tool/result', { turn: 1, step: 1, message: { ...value, source: { kind: 'tool', callId: 'call' }, content: [{ type: 'tool-result', toolCallId: 'call', content: [] }] } }, { surfaceOp: 'append' })],
['inbox', (value: SessionFormatJsonObject) => row('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [value] })],
['title', (value: SessionFormatJsonObject) => row('session/title-llm-request', { titleProvider: 'mock', messageSeqs: [2], route: config, system: 'title', messages: [{ ...value, source: { kind: 'plugin', plugin: 'dsh-session-title-llm' } }], maxTokens: 20 })],
] satisfies [string, (value: SessionFormatJsonObject) => SessionFormatEvent][]
describe('structural prompt history regressions', () => {
it('preserves every request across step and turn changes, repeated text, clear and reintroduction', () => {
const input = dense([
...opening(), user('a'), request('alpha'), row('step/end', { turn: 1, step: 1 }),
row('step/start', { turn: 1, step: 2 }), user('b'), request('beta'), request('beta'), ...closing(1, 2),
...opening(2), user('c'), request(), request('alpha'), ...closing(2),
])
const before = JSON.stringify(input)
const target = migrate(input)
const a = visible('user', 'a')
const b = visible('user', 'b')
const c = visible('user', 'c')
expect(requestMessages(target.events)).toEqual([
[visible('system', 'alpha'), a],
[visible('system', 'beta'), a, b],
[visible('system', 'beta'), a, b],
[a, b, c],
[visible('system', 'alpha'), a, b, c],
])
expect(systems(target).map(event => [event.seq, event.time, (event.data as SessionFormatJsonObject)['turn'], (event.data as SessionFormatJsonObject)['step'], event['surfaceOp'], event['sourceEventSeqs']])).toEqual([
[2, input[1]!.time, 1, 1, 'append', undefined],
[4, input[3]!.time, 1, 1, { op: 'replace', startSeq: 2, endSeq: 2 }, [2]],
[9, input[7]!.time, 1, 2, { op: 'replace', startSeq: 4, endSeq: 4 }, [4]],
[17, input[14]!.time, 2, 1, { op: 'replace', startSeq: 9, endSeq: 9 }, [9]],
[19, input[15]!.time, 2, 1, { op: 'replace', startSeq: 17, endSeq: 17 }, [17]],
])
expect(target.events.filter(event => event.type === 'user/message').map(event => event.data)).toEqual([message('a'), message('b'), message('c')])
expect(target.events.filter(event => event.type !== 'system/message').map(event => [event.type, event.time])).toEqual(input.map(event => [event.type, event.time]))
expect(target.events.filter(event => event.type === 'request/header').map(event => event.data)).toEqual(Array.from({ length: 5 }, () => ({ reason: 'initial', header: { config } })))
expect(roundTrip(target)).toEqual(target)
expect(JSON.stringify(input)).toBe(before)
})
it('keeps generated identities stable across reads but distinct across Session IDs and source anchors', () => {
const input = dense([...opening(), request('alpha'), request('beta'), request('alpha')])
const target = migrate(input)
const ids = systems(target).map(systemId)
expect(new Set(ids).size).toBe(4)
expect(systems(migrate(input)).map(systemId)).toEqual(ids)
expect(systems(migrate(input, { ...header, id: 'another-session' })).map(systemId).every(id => !ids.includes(id))).toBe(true)
expect(systems(migrate(input.map(event => ({ ...event, time: event.time + 1000 })))).map(systemId)).toEqual(ids)
})
it.each(carriers)('refuses generated head IDs in later %s message carriers', (name, carry) => {
const prefix = dense([...opening(), user('human'), request('prompt'), ...(name === 'tool' ? startedToolCall() : [])])
const id = systemId(systems(migrate(prefix))[0]!) as string
const ordinary = carry(message('ordinary'))
const control = migrate(dense([...prefix, ordinary, ...closing()]))
expect(control.events.at(-3)?.data).toEqual(name === 'title'
? { ...ordinary.data as SessionFormatJsonObject, messageSeqs: [3] }
: ordinary.data)
expect(roundTrip(control)).toEqual(control)
const input = dense([...prefix, carry(message(id)), ...closing()])
const before = JSON.stringify(input)
expect(() => migrate(input)).toThrow(SessionFormatUnsupportedMigrationError)
expect(() => migrate(input)).toThrow(/source message id collides/)
expect(JSON.stringify(input)).toBe(before)
})
})
describe('structural inherited ownership and reference regressions', () => {
it.each([undefined, 13])('maps the last inherited marker independently of later local prompt insertions (source cut %s)', (cut) => {
const input = dense([
...turn(1, 'grandparent'), row('session/end-seed', { inherited: true }),
...turn(2, 'parent'), row('session/end-seed', { inherited: true }),
...turn(3, 'local'), row('session/end-seed', {}),
])
const target = migrate(input, { ...header, isSeeded: true, parentSession: 'parent' }, cut)
expect(target.inheritedEventCount).toBe(16)
expect(target.events.filter(event => event.type === 'session/end-seed')).toEqual([
{ ...input[6], seq: 8 }, { ...input[13], seq: 16 }, { ...input[20], seq: 24 },
])
expect(systems(target).map(event => event.seq < target.inheritedEventCount)).toEqual([true, true, true, false])
expect(requestMessages(target.events)).toEqual([
[visible('system', 'grandparent'), visible('user', 'user-1')],
[visible('system', 'parent'), visible('user', 'user-1'), visible('user', 'user-2')],
[visible('system', 'local'), visible('user', 'user-1'), visible('user', 'user-2'), visible('user', 'user-3')],
])
expect(roundTrip(target)).toEqual(target)
})
it('keeps a zero-length inherited prefix when the first step and all generated systems are local', () => {
const input = dense([row('session/end-seed', { inherited: true }), ...turn(1, 'local')])
const target = migrate(input, { ...header, isSeeded: true, parentSession: 'parent' }, 0)
expect(target.inheritedEventCount).toBe(0)
expect(target.events[0]).toEqual(input[0])
expect(systems(target).map(event => event.seq)).toEqual([3, 5])
expect(roundTrip(target)).toEqual(target)
})
it('remaps references on both sides of a prompt insertion without reinterpreting same-Session captures or framed title input', () => {
const capture = { sessionId: header.id, label: 'same-session', capturedThroughSeq: 2, capturedFormatVersion: 2, compacted: false, originalMessages: 1, retainedMessages: 1, omittedMessages: 0, omittedBytes: 0, truncated: false, inputIndex: 0 }
const recalled = { ...message('capture'), source: { kind: 'session-reference', form: 'recall', version: 1, references: [capture] } }
const titleInput = { ...message('title-input'), source: { kind: 'plugin', plugin: 'dsh-session-title-llm' }, content: [{ type: 'text', text: 'Generate the session title from this JSON array of human messages:\n[{"seq":2,"text":"a"},{"seq":4,"text":"b"}]' }] }
const input = dense([
...opening(), user('a'), request('prompt'), user('b'),
row('user/message', recalled, { surfaceOp: 'append', sourceEventSeqs: [2, 4] }),
row('command/run', { commandId: 'command', name: 'recall', source: { kind: 'user' } }),
row('command/done', { commandId: 'command', kind: 'success', sourceEventSeq: 5 }),
row('session/title', { title: 'title', messageSeqs: [2, 4], source: { kind: 'fallback' } }),
row('session/title-llm-request', { titleProvider: 'mock', messageSeqs: [2, 4], route: config, system: 'title prompt', messages: [titleInput], maxTokens: 20 }),
])
const target = migrate(input)
expect(target.events[7]).toEqual({ ...input[5], seq: 7, sourceEventSeqs: [3, 6] })
expect(target.events[7]?.data).toEqual(recalled)
expect(target.events[9]?.data).toEqual({ commandId: 'command', kind: 'success', sourceEventSeq: 7 })
expect(target.events[10]?.data).toEqual({ title: 'title', messageSeqs: [3, 6], source: { kind: 'fallback' } })
expect(target.events[11]?.data).toEqual({ titleProvider: 'mock', messageSeqs: [3, 6], route: config, system: 'title prompt', messages: [titleInput], maxTokens: 20 })
expect(roundTrip(target)).toEqual(target)
})
})
describe('nested source audit regressions', () => {
it.each(carriers.filter(([name]) => name !== 'assistant' && name !== 'tool'))('refuses unknown content in %s messages even when the event is ignorable', (_name, carry) => {
const input = dense([...opening(), user('human'), { ...carry({ ...message('future'), content: [{ type: 'future-block', sourceEventSeq: 2 }] }), ignorable: true }])
expect(() => migrate(input)).toThrow(/cannot safely transform unclassified message content/)
})
it('audits nested tool-result content instead of preserving unknown blocks as opaque tool JSON', () => {
const result = (content: SessionFormatJsonObject[]) => row('tool/result', { turn: 1, step: 1, message: {
id: 'result', role: 'user', source: { kind: 'tool', callId: 'call' },
content: [{ type: 'tool-result', toolCallId: 'call', content }],
} }, { surfaceOp: 'append', ignorable: true })
const prefix = [...opening(), request('prompt'), ...startedToolCall()]
const ordinary = result([{ type: 'text', text: 'file contents' }])
const control = migrate(dense([...prefix, ordinary, ...closing()]))
expect(control.events.at(-3)?.data).toEqual(ordinary.data)
expect(roundTrip(control)).toEqual(control)
const unknown = result([{ type: 'future-block', sourceEventSeq: 2 }])
expect(() => migrate(dense([...prefix, unknown, ...closing()]))).toThrow(/cannot safely transform unclassified message content/)
})
})
@@ -920,7 +920,7 @@ describe('JSONL immutable generation publication', () => {
}
})
it('publishes only the final generation across a multi-edge migration', async () => {
it('publishes only the adapter target generation beside the source', async () => {
const root = await tempRoot()
const format = adapter()
const request = options(root, 'none', format)
@@ -0,0 +1,199 @@
/** Durable EOF refusals preserve historical generations and never fall back from native V3. */
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 { createHash } from 'node:crypto'
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, type JsonlCompression } from '../src/format.ts'
import { compressZstdFrame } from '../src/zstd.ts'
const id = SessionId('migration-refusal')
const config = { provider: 'historical', model: 'historical-model' }
const question = {
id: 'question', role: 'user', source: { kind: 'user' },
content: [{ type: 'text', text: 'Read the saved migration audit.' }],
}
const dispatch = {
rootCallId: 'root-call', parentCallId: 'root-call', subCallId: 'read-call',
name: 'read', arguments: { file_path: 'migration-audit.txt' },
}
const prefix: readonly SessionFormatJsonObject[] = [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'step/start', data: { turn: 1, step: 1 } },
{ type: 'user/message', data: question, surfaceOp: 'append' },
{ type: 'request/header', data: { header: { config, system: 'Inspect the durable audit.' }, reason: 'initial' } },
]
const nativePrefix: readonly SessionFormatJsonObject[] = [
...prefix.slice(0, 2),
{ type: 'system/message', surfaceOp: 'append', data: {
turn: 1, step: 1, message: {
id: 'native-system', role: 'system', source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
content: [{ type: 'text', text: 'Inspect the durable audit.' }],
},
} },
{ type: 'user/message', data: question, surfaceOp: 'append' },
{ type: 'request/header', data: { header: { config }, reason: 'initial' } },
]
function ptcRow(type: string): SessionFormatJsonObject {
return { type, data: type.endsWith('-start') ? dispatch : {
...dispatch, isError: false, content: [{ type: 'text', text: 'Audit is intact.' }],
} }
}
const migrationRefusals = [
...['tool/ptc-dispatch-start', 'tool/ptc-dispatch'].flatMap(type => [false, true].map(ignorable => ({
name: type + (ignorable ? ' (ignorable)' : ' (required)'),
tail: { ...ptcRow(type), ...(ignorable ? { ignorable: true } : {}) },
diagnostic: 'format v2 to v3 cannot safely transform unclassified event ' + type,
}))),
{
name: 'delivery activation claiming V3',
tail: { type: 'session-log-deepseek/delivery-accepted', data: {
sessionId: id, throughSeq: prefix.length - 1, sessionFormatVersion: 3,
} },
diagnostic: '@deepseek-ai/dsh-session-format-v2-to-v3 refuses this format v2 Session: format v2 delivery marker claims target format v3',
},
{
name: 'source message colliding with the generated system ID',
tail: { type: 'user/message', surfaceOp: 'append', data: {
...question,
id: 'v2-to-v3-system-' + createHash('sha256')
.update(JSON.stringify(['session-format-v2-to-v3', id, 1, 'step/start'])).digest('hex'),
} },
diagnostic: 'source message id collides with a generated system message id',
},
] satisfies readonly { name: string; tail: SessionFormatJsonObject; diagnostic: string }[]
const nativeRefusals = [
...['tool/code-dispatch-start', 'tool/code-dispatch'].map(type => ({
name: type,
tail: ptcRow(type),
diagnostic: 'format v3 contains unknown event type ' + JSON.stringify(type) + ' at seq ' + String(nativePrefix.length),
})),
{
name: 'retired request/header.system',
tail: { type: 'request/header', data: {
header: { config, system: 'This prompt must not be discarded.' }, reason: 'change',
} },
diagnostic: 'format v3 request/header rejects retired header.system',
},
] satisfies readonly { name: string; tail: SessionFormatJsonObject; diagnostic: string }[]
const modes = (['none', 'zstd'] as const).flatMap(compression =>
(['read', 'write'] as const).map(access => ({ compression, access })),
)
let root: string
const contexts: Context[] = []
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-migration-refusal-'))
})
afterEach(async () => {
try {
for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
}
})
async function mount(compression: JsonlCompression): Promise<Context> {
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(version: 2 | 3, compression: JsonlCompression, rows: readonly SessionFormatJsonObject[]) {
const path = generationLogPath(root, undefined, id, version, compression)
const header = { type: 'session', version, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
const events = rows.map((row, seq) => ({ ...row, seq, time: 1001 + seq }))
// The offending EOF row occupies its own complete frame, not a torn compressed suffix.
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, mtimeNs: identity.mtimeNs, ctimeNs: identity.ctimeNs,
}
}
async function expectRefusal(ctx: Context, access: 'read' | 'write', path: string, message: string) {
// Close an unexpectedly successful open before the rejection assertion fails.
const opened = ctx.sessionPersistence.open(id, access).then(async (handle) => { await handle.close() })
await expect(opened).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
await expect(opened).rejects.toMatchObject({ message, location: { kind: 'jsonl', path } })
}
async function expectOnlyGenerations(paths: readonly string[]) {
const directory = dirname(paths[0]!)
// A released write lease keeps session.lock; every other extra entry is forbidden.
expect((await readdir(directory)).filter(name => name !== 'session.lock').sort())
.toEqual(paths.map(path => basename(path)).sort())
}
describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compression, access }) => {
it.each(migrationRefusals)('refuses V2 $name without publishing or discarding a tail', async ({ tail, diagnostic }) => {
const path = await store(2, compression, [...prefix, tail])
const original = await observe(path)
const message = diagnostic + '; source v2 artifact remains unchanged (raw log: ' + path + ')'
const ctx = await mount(compression)
for (let attempt = 0; attempt < 2; attempt += 1) {
await expectRefusal(ctx, access, path, message)
expect(await observe(path)).toEqual(original)
await expectOnlyGenerations([path])
for (const targetCompression of ['none', 'zstd'] as const) {
await expect(stat(generationLogPath(root, undefined, id, 3, targetCompression)))
.rejects.toMatchObject({ code: 'ENOENT' })
}
}
})
it.each(nativeRefusals)('refuses native V3 $name instead of falling back to readable V2', async ({ tail, diagnostic }) => {
const lowerPath = await store(2, compression, prefix)
const lower = await observe(lowerPath)
const ctx = await mount(compression)
const reader = await ctx.sessionPersistence.open(id, 'read')
try {
expect(reader.header.version).toBe(3)
const restored = await reader.read()
expect(restored.events.map(event => event.type)).toEqual([
'turn/start', 'step/start', 'system/message', 'user/message', 'system/message', 'request/header',
])
expect(restored.events.find(event => event.type === 'user/message')?.data).toEqual(question)
} finally {
await reader.close()
}
expect(await observe(lowerPath)).toEqual(lower)
await expectOnlyGenerations([lowerPath])
const path = await store(3, compression, [...nativePrefix, tail])
const original = await observe(path)
const message = diagnostic + ' (raw log: ' + path + ')'
for (let attempt = 0; attempt < 2; attempt += 1) {
await expectRefusal(ctx, access, path, message)
expect(await observe(path)).toEqual(original)
expect(await observe(lowerPath)).toEqual(lower)
await expectOnlyGenerations([lowerPath, path])
}
})
})
@@ -0,0 +1,301 @@
/** Durable composition of historical chunk collapse and V3 system/reference migration. */
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
import {
assertReleasedV2Header, RELEASED_V2_EVENT_TYPES, releasedV2SessionFormatCodec,
restoreReleasedV2Artifact, sessionFormatV1ToV2,
} from '@deepseek-ai/dsh-session-format-v1-to-v2'
import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { appendFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { scheduler } from 'node:timers/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { JsonlGenerationSourceChangedError } from '../src/generation.ts'
import { generationLogPath, type JsonlCompression } from '../src/format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
const id = SessionId('multi-edge-seeded')
const config = { provider: 'mock', model: 'mock' }
const roots: string[] = []
const contexts: Context[] = []
const v2EventTypes = new Set(RELEASED_V2_EVENT_TYPES)
const v2Catalog = createSessionFormatCatalog({
currentVersion: 2,
codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec],
currentEncoder: releasedV2SessionFormatCodec,
migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2],
restoreCurrent: artifact => restoreReleasedV2Artifact(artifact, v2EventTypes),
restoreTransformedCurrent: artifact => restoreReleasedV2Artifact(artifact, v2EventTypes),
restoreCurrentHeader(header) {
assertReleasedV2Header(header)
return header
},
})
afterEach(async () => {
try {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
} finally {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
}
})
function message(role: 'user' | 'assistant', text: string) {
return {
id: text, role, content: [{ type: 'text', text }],
source: role === 'user' ? { kind: 'user' } : { kind: 'model', ...config },
}
}
function event(type: string, seq: number, data: object) {
return { type, seq, time: 100 + seq, data }
}
function request(seq: number, system: string) {
return event('request/header', seq, { header: { config, system }, reason: 'change' })
}
/** Packed rows consume four chunk coordinates before the inherited Assistant message. */
function historicalRows() {
return [
event('turn/start', 0, { turn: 1 }),
event('step/start', 1, { turn: 1, step: 1 }),
{ ...event('user/message', 2, message('user', 'question')), surfaceOp: 'append' },
request(3, 'seed prompt'),
{ type: 'text-chunks', seq0: 4, time0: 104, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['he', 'l', 'lo'] } },
event('assistant/chunk', 7, { turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } } }),
{ ...event('assistant/message', 8, { turn: 1, step: 1, message: message('assistant', 'hello') }), surfaceOp: 'append', sourceEventSeqs: [[4, 7]] },
event('step/end', 9, { turn: 1, step: 1 }),
event('turn/end', 10, { turn: 1, reason: { kind: 'completed' } }),
event('session/end-seed', 11, {}),
event('turn/start', 12, { turn: 2 }),
event('step/start', 13, { turn: 2, step: 1 }),
{ ...event('user/message', 14, message('user', 'follow-up')), surfaceOp: 'append' },
request(15, 'changed prompt'),
event('compaction/prune', 16, { shadowedRange: { start: 2, end: 8 }, shadowedSeqs: [2, 8], shadowedTokenCount: 20 }),
{ ...event('user/message', 17, message('user', 'summary')), surfaceOp: { op: 'replace', start: 2, end: 8 }, sourceEventSeqs: [2, 8] },
event('command/run', 18, { commandId: 'command', name: 'test', source: { kind: 'user' } }),
event('command/done', 19, { commandId: 'command', kind: 'success', sourceEventSeq: 17 }),
event('session/title', 20, { title: 'title', messageSeqs: [2, 14], source: { kind: 'fallback' } }),
request(21, 'changed prompt'),
event('step/end', 22, { turn: 2, step: 1 }),
event('turn/end', 23, { turn: 2, reason: { kind: 'completed' } }),
]
}
async function mount(root: string, compression: JsonlCompression) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(JsonlSessionPersistence, { root, compression })
return ctx
}
async function seed(version: 0 | 1, compression: JsonlCompression, refuse = false) {
const root = await mkdtemp(join(tmpdir(), 'dsh-multi-edge-publication-'))
roots.push(root)
const path = generationLogPath(root, undefined, id, version, compression)
await mkdir(dirname(path), { recursive: true })
const header = { type: 'session', version, id, createdAt: 1, parentSession: 'parent', delegationDepth: 0, seedLength: 11 }
const rows = refuse ? [...historicalRows().slice(0, -1), request(23, 'outside step')] : historicalRows()
const headerLine = JSON.stringify(header) + '\n'
const body = rows.map(row => JSON.stringify(row)).join('\n') + '\n'
const bytes = compression === 'none' ? Buffer.from(headerLine + body) : Buffer.concat([
await compressZstdFrame(headerLine), await compressZstdFrame(body),
])
await writeFile(path, bytes)
return { root, path, header, rows }
}
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, mtimeNs: identity.mtimeNs, ctimeNs: identity.ctimeNs,
}
}
async function readSession(ctx: Context, access: 'read' | 'write') {
const handle = await ctx.sessionPersistence.open(id, access)
try {
const result = await handle.read()
const session = Session.fromRestore(id, result.events, handle.header, handle.inheritedEventCount, result.eventState)
if (access === 'write') await handle.flush()
return { header: handle.header, events: result.events, cut: handle.inheritedEventCount, session }
} finally {
await handle.close()
}
}
function visible(role: 'system' | 'user' | 'assistant', text: string) {
return { role, content: [{ type: 'text', text }] }
}
function assertRequests(events: readonly SessionEvent[], header: SessionHeader) {
const requests = events.filter(event => event.type === 'request/header')
expect(requests.map(event => event.data.header)).toEqual([{ config }, { config }, { config }])
expect(requests.map(event => Session.fromRestore(
id, events.slice(0, event.seq + 1), header, SessionLogOffset(0), 'shared-frozen',
).deriveMessages().map(({ role, content }) => ({ role, content })))).toEqual([
[visible('system', 'seed prompt'), visible('user', 'question')],
[visible('system', 'changed prompt'), visible('user', 'question'), visible('assistant', 'hello'), visible('user', 'follow-up')],
[visible('system', 'changed prompt'), visible('user', 'summary'), visible('user', 'follow-up')],
])
}
function assertMigrated(result: Awaited<ReturnType<typeof readSession>>) {
const { events, header, cut, session } = result
expect(header).toEqual({ version: 3, id, createdAt: 1, parentSession: 'parent', delegationDepth: 0, isSeeded: true })
expect(events.map(event => event.seq)).toEqual(Array.from({ length: 23 }, (_, seq) => seq))
expect(events.filter(event => event.type.startsWith('assistant/'))).toEqual([{
type: 'assistant/message', seq: 6, time: 108, surfaceOp: 'append',
data: { turn: 1, step: 1, message: message('assistant', 'hello'), stream: [
{ type: 'text-chunks', time0: 104, index: 0, dt: [1, 1], texts: ['he', 'l', 'lo'] },
{ type: 'chunk', time: 107, chunk: { type: 'finish', reason: { kind: 'stop' } } },
] },
}])
expect(events.filter(event => event.type === 'system/message').map(event => ({
seq: event.seq, content: event.data.message.content, surfaceOp: event.surfaceOp, sourceEventSeqs: event.sourceEventSeqs,
}))).toEqual([
{ seq: 2, content: [], surfaceOp: 'append', sourceEventSeqs: undefined },
{ seq: 4, content: visible('system', 'seed prompt').content, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 }, sourceEventSeqs: [2] },
{ seq: 13, content: visible('system', 'changed prompt').content, surfaceOp: { op: 'replace', startSeq: 4, endSeq: 4 }, sourceEventSeqs: [4] },
])
expect(events[15]).toMatchObject({ type: 'compaction/prune', data: { shadowedRange: { start: 3, end: 6 }, shadowedSeqs: [3, 6] } })
expect(events[16]).toMatchObject({ type: 'user/message', surfaceOp: { op: 'replace', startSeq: 3, endSeq: 6 }, sourceEventSeqs: [3, 6] })
expect(events[18]).toMatchObject({ type: 'command/done', data: { sourceEventSeq: 16 } })
expect(events[19]).toMatchObject({ type: 'session/title', data: { messageSeqs: [3, 12] } })
expect(cut).toBe(9)
expect(events[9]).toEqual({ type: 'session/end-seed', seq: 9, time: 111, data: { inherited: true } })
expect(session.inheritedEventCount).toBe(9)
expect(session.firstLiveSeq).toBe(23)
expect(session.isOwnSeq(SessionSeq(8))).toBe(false)
expect(session.isOwnSeq(SessionSeq(9))).toBe(true)
expect(session.ownEvents()).toEqual([
...events.slice(9),
expect.objectContaining({ type: 'session/end-seed', seq: 23, data: {} }),
])
expect(session.surface.nodes).toEqual([13, 16, 12])
expect(session.deriveMessages().map(({ role, content }) => ({ role, content }))).toEqual([
visible('system', 'changed prompt'), visible('user', 'summary'), visible('user', 'follow-up'),
])
assertRequests(events, header)
}
async function publishedRows(path: string, compression: JsonlCompression) {
const bytes = await readFile(path)
let plaintext = bytes
if (compression === 'zstd') {
const { frames, tornStart } = scanZstdFrames(bytes)
expect(tornStart).toBeUndefined()
expect(frames.length).toBeGreaterThan(0)
plaintext = Buffer.concat(await Promise.all(frames.map(frame => decompressZstdFrame(bytes.subarray(frame.start, frame.end)))))
}
return plaintext.toString('utf8').trimEnd().split('\n').map((line): unknown => JSON.parse(line))
}
describe.each([0, 1] as const)('V%s multi-edge durable publication', (version) => {
it.each(['none', 'zstd'] as const)('publishes only V3 after chunk collapse, system changes, and reference remapping (%s)', async (compression) => {
const { root, path } = await seed(version, compression)
const source = await observe(path)
const ctx = await mount(root, compression)
const prepared = await readSession(ctx, 'read')
assertMigrated(prepared)
await ctx.sessionPersistence.flush()
expect(await observe(path)).toEqual(source)
expect(await readdir(dirname(path))).toEqual([basename(path)])
const written = await readSession(ctx, 'write')
assertMigrated(written)
expect(written.events).toEqual(prepared.events)
await ctx.fiber.dispose()
contexts.splice(contexts.indexOf(ctx), 1)
const successor = generationLogPath(root, undefined, id, 3, compression)
expect((await readdir(dirname(path))).filter(name => name !== 'session.lock').sort())
.toEqual([basename(path), basename(successor)].sort())
expect(await publishedRows(successor, compression)).toEqual([{ type: 'session', ...prepared.header }, ...prepared.events])
expect(await observe(path)).toEqual(source)
const published = await observe(successor)
const reopened = await mount(root, compression)
const native = await readSession(reopened, 'read')
assertMigrated(native)
expect(native.events).toEqual(prepared.events)
expect(native.cut).toBe(prepared.cut)
const repeated = await readSession(reopened, 'write')
expect(repeated.events).toEqual(prepared.events)
expect(repeated.cut).toBe(prepared.cut)
await reopened.sessionPersistence.flush()
expect(await observe(path)).toEqual(source)
expect(await observe(successor)).toEqual(published)
})
it.each(['none', 'zstd'] as const)('re-prepares populated history after source drift rejects stale publication (%s)', async (compression) => {
const { root, path } = await seed(version, compression)
const source = await observe(path)
const ctx = await mount(root, compression)
const prepared = await readSession(ctx, 'read')
assertMigrated(prepared)
const tail = event('feedback/record', 24, { text: 'arrived after preparation' })
const line = JSON.stringify(tail) + '\n'
const appended = compression === 'none' ? Buffer.from(line) : await compressZstdFrame(line)
const yieldSpy = vi.spyOn(scheduler, 'yield').mockImplementationOnce(async () => {
await appendFile(path, appended)
})
try {
await expect(readSession(ctx, 'write')).rejects.toBeInstanceOf(JsonlGenerationSourceChangedError)
expect(yieldSpy).toHaveBeenCalled()
} finally {
yieldSpy.mockRestore()
}
const changed = await observe(path)
expect(changed.bytes).toEqual(Buffer.concat([source.bytes, appended]))
expect(changed).toMatchObject({ dev: source.dev, ino: source.ino })
expect((await readdir(dirname(path))).filter(name => name !== 'session.lock')).toEqual([basename(path)])
const retried = await readSession(ctx, 'write')
const expected = [...prepared.events, { ...tail, seq: 23 }]
expect(retried.events).toEqual(expected)
expect(retried.cut).toBe(prepared.cut)
assertRequests(retried.events, retried.header)
const successor = generationLogPath(root, undefined, id, 3, compression)
expect(await publishedRows(successor, compression)).toEqual([{ type: 'session', ...prepared.header }, ...expected])
expect((await readdir(dirname(path))).filter(name => name !== 'session.lock').sort())
.toEqual([basename(path), basename(successor)].sort())
await ctx.fiber.dispose()
contexts.splice(contexts.indexOf(ctx), 1)
const reopened = await mount(root, compression)
const native = await readSession(reopened, 'read')
expect(native.events).toEqual(expected)
expect(native.cut).toBe(prepared.cut)
assertRequests(native.events, native.header)
expect(await observe(path)).toEqual(changed)
})
it.each(['none', 'zstd'] as const)('refuses a late V3 prompt outside a step without publishing earlier edges (%s)', async (compression) => {
const { root, path, header, rows } = await seed(version, compression, true)
const restoreV2 = v2Catalog.createRestore(header, { recovery: 'strict', validation: 'current' })
for (const row of rows) restoreV2.decodeRow(row)
const validV2 = restoreV2.finish()
expect(validV2.inheritedEventCount).toBe(7)
expect(validV2.events.slice(-2)).toEqual([
{ ...event('step/end', 22, { turn: 2, step: 1 }), seq: 18 },
{ ...request(23, 'outside step'), seq: 19 },
])
const source = await observe(path)
const ctx = await mount(root, compression)
for (const access of ['read', 'write', 'read', 'write'] as const) {
const failure = readSession(ctx, access)
await expect(failure).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
await expect(failure).rejects.toThrow(/outside an open step/)
await ctx.sessionPersistence.flush()
expect(await observe(path)).toEqual(source)
expect((await readdir(dirname(path))).filter(name => name !== 'session.lock')).toEqual([basename(path)])
}
})
})