feat(session): add the V4 integration format and retain V3 replay inputs
This commit is contained in:
1 parent
5e4cfe670f
commit
669b724a78
91 files changed
+74142
-406
No files matched your search
+2
-2
@@ -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: d566459af64f4177ed0135813e75aaf77480823c
|
||||
2026-08-31-released-session-format-migrations.zh.md: f82b406117691197d808111f1c8aa4c722d4bab2
|
||||
2026-08-31-released-session-format-migrations.md: 6c236431a69a004d969b321f896041b791b2900a
|
||||
2026-08-31-released-session-format-migrations.zh.md: 3c0e242770a5056c621e971befb023869926bf6f
|
||||
+3
@@ -59,6 +59,7 @@ JSONL record
|
||||
→ v0-to-v1 stage
|
||||
→ v1-to-v2 stage
|
||||
→ v2-to-v3 stage
|
||||
→ v3-to-v4 stage
|
||||
→ current event collector
|
||||
```
|
||||
|
||||
@@ -66,6 +67,8 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
|
||||
|
||||
### Adjacent version ownership
|
||||
|
||||
The [V3-to-V4 specification](../../../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) owns an identity body conversion: it advances the header version while preserving admitted events and inherited cuts. It reuses the frozen V3 codec from V2→V3. Generation-aware delivery validation prevents a historical acknowledgement from acquiring a current watermark meaning solely through the header change; earlier edges retain their own source-admission policies.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
+3
@@ -59,6 +59,7 @@ JSONL record
|
||||
→ v0-to-v1 stage
|
||||
→ v1-to-v2 stage
|
||||
→ v2-to-v3 stage
|
||||
→ v3-to-v4 stage
|
||||
→ current event collector
|
||||
```
|
||||
|
||||
@@ -66,6 +67,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
|
||||
|
||||
### 相邻版本所有权
|
||||
|
||||
[V3 到 V4 规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)负责 body 的恒等转换:它推进 header 版本,同时保留已接纳的事件与继承切点。它复用 V2→V3 中冻结的 V3 codec。按 generation 校验 delivery 可防止历史确认仅因 header 变化而获得当前水位含义;更早的 migration edge 保留各自的来源准入策略。
|
||||
|
||||
[V2 到 V3 投递保护](../../../../packages/session/session-format-v2-to-v3/README.zh.md#delivery-guards)防止源代中被忽略的标记仅因头部变化就成为有效上传水位。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
|
||||
|
||||
[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)负责包接线、当前消费方、快照后继代际与验证命令。
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
@@ -472,9 +473,9 @@ function assertClean(world: ScrollWorld): void {
|
||||
expect(world.tripwire.warnings).toEqual([])
|
||||
}
|
||||
|
||||
it('generates a native V3 scroll seed with a protected system head and intact references', () => {
|
||||
it('generates a current-format scroll seed with a protected system head and intact references', () => {
|
||||
const { header, events } = parseSeedFixture(HISTORY_FIXTURE.log)
|
||||
expect(header.version).toBe(3)
|
||||
expect(header.version).toBe(SESSION_FORMAT_VERSION)
|
||||
expect(events.slice(0, 5).map(event => event.type)).toEqual([
|
||||
'turn/start', 'step/start', 'system/message', 'user/message', 'session/title',
|
||||
])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/** Cold V2 restoration mounts the shipped PTC preset and publishes only a V3 successor. */
|
||||
/** Cold V2 restoration mounts the shipped PTC preset and publishes only the current successor. */
|
||||
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { generationLogPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts'
|
||||
import { scanZstdFrames } from '../../../packages/session/session-persistence-jsonl/src/zstd.ts'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
@@ -28,7 +28,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
|
||||
const rows: Record<string, unknown>[] = events.map((event, seq) => ({ ...event, seq, time: seq + 2 }))
|
||||
const source = Buffer.concat([header, ...rows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n'))))
|
||||
const predecessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, id, 2, 'zstd')
|
||||
const successor = join(dirname(predecessor), 'session.v3.jsonl.zstd')
|
||||
const successor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, id, SESSION_FORMAT_VERSION, 'zstd')
|
||||
await mkdir(dirname(predecessor), { recursive: true })
|
||||
await writeFile(predecessor, source)
|
||||
|
||||
@@ -53,7 +53,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
|
||||
const published = Buffer.concat(scanZstdFrames(publishedBytes).frames
|
||||
.map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8')
|
||||
const expected = [
|
||||
{ ...header, version: 3, agentPreset: 'ptc' },
|
||||
{ ...header, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' },
|
||||
...rows.map(row => row['type'] === 'agent-preset/selected'
|
||||
&& (row['data'] as { agentPreset: string }).agentPreset === 'code'
|
||||
? { ...row, data: { agentPreset: 'ptc' } }
|
||||
@@ -65,7 +65,7 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t
|
||||
expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context))
|
||||
expect(await readFile(predecessor)).toEqual(source)
|
||||
expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort())
|
||||
.toEqual(['session.v2.jsonl.zstd', 'session.v3.jsonl.zstd'])
|
||||
.toEqual(['session.v2.jsonl.zstd', `session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`])
|
||||
} finally {
|
||||
await scaffold.close()
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: ba691e83e0e81bf608f090c56cca6b797074116b
|
||||
config-catalog.zh.md: 78d39e6e85329d797699b0a6e9e151e13d48fa5e
|
||||
config-catalog.md: baf3ba3930f441652d772de825a55c32fe7af7bc
|
||||
config-catalog.zh.md: 3edfb7038ac531a8255a42067e7c69d7f7a20b58
|
||||
@@ -3798,6 +3798,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-session-format-v0-to-v1` ([`packages/session/session-format-v0-to-v1/src/index.ts`](../packages/session/session-format-v0-to-v1/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-format-v1-to-v2` ([`packages/session/session-format-v1-to-v2/src/index.ts`](../packages/session/session-format-v1-to-v2/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-format-v2-to-v3` ([`packages/session/session-format-v2-to-v3/src/index.ts`](../packages/session/session-format-v2-to-v3/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-format-v3-to-v4` ([`packages/session/session-format-v3-to-v4/src/index.ts`](../packages/session/session-format-v3-to-v4/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-snapshot` ([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts))
|
||||
|
||||
@@ -3799,6 +3799,7 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-session-format-v0-to-v1`([`packages/session/session-format-v0-to-v1/src/index.ts`](../packages/session/session-format-v0-to-v1/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-format-v1-to-v2`([`packages/session/session-format-v1-to-v2/src/index.ts`](../packages/session/session-format-v1-to-v2/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-format-v2-to-v3`([`packages/session/session-format-v2-to-v3/src/index.ts`](../packages/session/session-format-v2-to-v3/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-format-v3-to-v4`([`packages/session/session-format-v3-to-v4/src/index.ts`](../packages/session/session-format-v3-to-v4/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-snapshot`([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts))
|
||||
|
||||
@@ -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: 6ebc6bd2062acd88dab953bc227bc1046b13f083
|
||||
adding-a-session-format-version.zh.md: c66a045303f96c2ef237cf3b1c1dc598e9c8f5f6
|
||||
adding-a-session-format-version.md: 84dab0f385d8eaae9da952e54a148060437ca285
|
||||
adding-a-session-format-version.zh.md: 11c918fb0a4ce839363cc9f4e7f4341a60bbbd77
|
||||
@@ -69,7 +69,7 @@ Verify both read and write paths. Header-only listing must not read bodies or pu
|
||||
<a id="snapshot-successors"></a>
|
||||
## 5. Create snapshot successors
|
||||
|
||||
Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. After implementing N+1, keep each historical file and generate its successor using the target version’s canonical parent and child filenames. Never rename a predecessor to the target filename or change only its header.
|
||||
Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). The [corpus policy](../../scripts/session-snapshot-corpus-policy.ts) keeps its declared baseline as replay input so upgrades exercise the adjacent chain; a writer bump does not require refreshing that corpus. Select the owning scenario when a current-generation successor is needed, keep each historical file, and use the target version’s canonical parent and child filenames. Never rename a predecessor to the target filename or change only its header.
|
||||
|
||||
For unchanged replay input, use keyless refresh on the owner, then replay without write-back. These SDK commands use `text-turn` and the checkout's writer version. Implement and wire N+1 before using them to generate that version, and select the actual affected owner for a feature:
|
||||
|
||||
@@ -80,7 +80,7 @@ pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn
|
||||
|
||||
Review the new generation, request sidecars, and protocol output together. Verify every predecessor remains byte-identical and that parent/child roles remain contiguous. Selection uses the numerically highest generation, so update shared references to the owner's selected parent. Do not use the packed-layout migrator as a version upgrader. If the model transcript must change, the scenario owner uses live recording under the [testing policy](../testing.md), with its required provider key.
|
||||
|
||||
Keep deliberate historical cases explicit through `snapshot.yml`'s `sessionFormat.version` and supported `coverage` names; record and refresh leave their Session fixtures untouched. Update the [corpus policy](../../scripts/session-snapshot-corpus-policy.ts) for the current generation while retaining focused direct-edge, multi-hop, packed-row, retry/failure, and shipped-profile coverage. Check the corpus and both SDK projections; do not mass-refresh unrelated scenarios merely to silence a validation failure.
|
||||
Keep cases older than the retained baseline explicit through `snapshot.yml`'s `sessionFormat.version` and supported `coverage` names; record and refresh leave pinned Session fixtures untouched. Update the [corpus policy](../../scripts/session-snapshot-corpus-policy.ts) for the current generation while retaining focused direct-edge, multi-hop, packed-row, retry/failure, and shipped-profile coverage. Check the corpus and both SDK projections; do not mass-refresh unrelated scenarios merely to silence a validation failure.
|
||||
|
||||
<a id="validate"></a>
|
||||
## 6. Validate the integrated result
|
||||
|
||||
@@ -69,7 +69,7 @@ pnpm run gen-session-format-catalog
|
||||
<a id="snapshot-successors"></a>
|
||||
## 5. 创建快照后继代际
|
||||
|
||||
阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。实现 N+1 后,保留每份历史文件,并按目标版本的规范父子文件名生成后继文件。绝不将前代重命名为目标文件名,或仅修改其 header。
|
||||
阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。[语料策略](../../scripts/session-snapshot-corpus-policy.ts)保留其声明的 baseline 作为回放输入,让升级实际经过相邻迁移链;提高 writer 版本不要求 refresh 该语料。需要当前代际后继时,选择拥有数据的场景,保留每份历史文件,并使用目标版本的规范父子文件名。绝不将前代重命名为目标文件名,或仅修改其 header。
|
||||
|
||||
如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。以下 SDK 命令使用 `text-turn` 和工作区的写入器版本。先实现并接入 N+1,才能用它们生成该版本;功能变更应选择实际受影响的所有者:
|
||||
|
||||
@@ -80,7 +80,7 @@ pnpm run test:snapshot snapshots/sdk/sdk.snapshot.ts -t text-turn
|
||||
|
||||
一起审查新代际、请求伴随文件与协议输出。验证每个前代的字节保持相同,且父子角色连续。选择规则采用数值最高的代际,因此应将共享引用更新为所有者选中的父代际。不要把 packed 布局迁移器当作版本升级器。如果模型 transcript(文本记录)必须变化,由场景所有者按照[测试策略](../testing.zh.md)使用所需提供方密钥进行实时录制。
|
||||
|
||||
通过 `snapshot.yml` 的 `sessionFormat.version` 与受支持的 `coverage` 名称显式保留历史案例;record 和 refresh 不改动这些 Session fixture。更新[语料策略](../../scripts/session-snapshot-corpus-policy.ts)以采用当前代际,同时保留聚焦的直接迁移边、多跳、packed row、重试/失败及交付 profile 覆盖。检查语料和两个 SDK 投影;不要仅为消除校验失败而批量 refresh 无关场景。
|
||||
通过 `snapshot.yml` 的 `sessionFormat.version` 与受支持的 `coverage` 名称显式保留早于 baseline 的案例;record 和 refresh 不改动已 pin 的 Session fixture。更新[语料策略](../../scripts/session-snapshot-corpus-policy.ts)以采用当前代际,同时保留聚焦的直接迁移边、多跳、packed row、重试/失败及交付 profile 覆盖。检查语料和两个 SDK 投影;不要仅为消除校验失败而批量 refresh 无关场景。
|
||||
|
||||
<a id="validate"></a>
|
||||
## 6. 验证集成结果
|
||||
|
||||
@@ -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/module-graph.md
|
||||
module-graph.md: 719273c14784fcf5dd98b2fddabfb7666c666139
|
||||
module-graph.zh.md: b1098cc39fdc1d44f9d7edc3555d18a2f2c26bc5
|
||||
module-graph.md: b0f35fda6446f32ffd9d68fceda82484f8fe208f
|
||||
module-graph.zh.md: 5a8d81a8fc46e38dbd8379e9dc7e71d202b6ca08
|
||||
@@ -318,6 +318,7 @@ flowchart TD
|
||||
pkg_session_format_v0_to_v1["session-format-v0-to-v1"]
|
||||
pkg_session_format_v1_to_v2["session-format-v1-to-v2"]
|
||||
pkg_session_format_v2_to_v3["session-format-v2-to-v3"]
|
||||
pkg_session_format_v3_to_v4["session-format-v3-to-v4"]
|
||||
pkg_session_log_deepseek["session-log-deepseek"]
|
||||
pkg_session_persistence["session-persistence"]
|
||||
pkg_session_persistence_jsonl["session-persistence-jsonl"]
|
||||
@@ -1378,6 +1379,7 @@ flowchart TD
|
||||
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
|
||||
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
|
||||
| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — |
|
||||
| [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | — |
|
||||
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
|
||||
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
|
||||
|
||||
@@ -320,6 +320,7 @@ flowchart TD
|
||||
pkg_session_format_v0_to_v1["session-format-v0-to-v1"]
|
||||
pkg_session_format_v1_to_v2["session-format-v1-to-v2"]
|
||||
pkg_session_format_v2_to_v3["session-format-v2-to-v3"]
|
||||
pkg_session_format_v3_to_v4["session-format-v3-to-v4"]
|
||||
pkg_session_log_deepseek["session-log-deepseek"]
|
||||
pkg_session_persistence["session-persistence"]
|
||||
pkg_session_persistence_jsonl["session-persistence-jsonl"]
|
||||
@@ -1380,6 +1381,7 @@ flowchart TD
|
||||
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
|
||||
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
|
||||
| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — |
|
||||
| [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | — |
|
||||
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
|
||||
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: 99c24562c178b35944e5616729076d015eb3f4c6
|
||||
persistence-catalog.zh.md: a2c9590a799a1bd63745afaaad989be051015ae0
|
||||
persistence-catalog.md: 1f28ccf7178ce6c51b6fb2d00a43d1365760945a
|
||||
persistence-catalog.zh.md: 73bd6df4edd8427910d1e1c1da0c039a0a9732b4
|
||||
@@ -17,7 +17,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
|
||||
|
||||
| Root | Kind | SHA-256 | Resolved type |
|
||||
|---|---|---|---|
|
||||
| `SessionHeader` | header | `2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03` | [`packages/core/session/src/types.ts#SessionHeader`](#persistence-type-packagescoresessionsrctypestssessionheader) |
|
||||
| `SessionHeader` | header | `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` | [`packages/core/session/src/types.ts#SessionHeader`](#persistence-type-packagescoresessionsrctypestssessionheader) |
|
||||
| `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`packages/session/session-persistence-jsonl/src/format.ts#HeaderLine`](#persistence-type-packagessessionsession-persistence-jsonlsrcformattsheaderline) |
|
||||
| `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`SessionEventEnvelope`](#persistence-type-sessioneventenvelope) |
|
||||
| `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`event:agent-preset/selected`](#persistence-type-eventagent-presetselected) |
|
||||
@@ -1268,13 +1268,13 @@ SHA-256: `a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80`
|
||||
|
||||
`2`
|
||||
|
||||
<a id="persistence-type-3"></a>
|
||||
<a id="persistence-type-4"></a>
|
||||
|
||||
### `3`
|
||||
### `4`
|
||||
|
||||
SHA-256: `137ac60e26bcb192210d9a32a704b73b0924ed34019323defa1b787be95bd45e`
|
||||
SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0`
|
||||
|
||||
`3`
|
||||
`4`
|
||||
|
||||
<a id="persistence-type-aborted"></a>
|
||||
|
||||
@@ -4560,7 +4560,7 @@ One of:
|
||||
|
||||
### `packages/core/session/src/types.ts#SessionHeader`
|
||||
|
||||
SHA-256: `2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03`
|
||||
SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445`
|
||||
|
||||
Sources: [`packages/core/session/src/types.ts:93`](../packages/core/session/src/types.ts)
|
||||
|
||||
@@ -4574,7 +4574,7 @@ Sources: [`packages/core/session/src/types.ts:93`](../packages/core/session/src/
|
||||
| `isSeeded` | required | `boolean` |
|
||||
| `origin` | optional | `"subagent"` |
|
||||
| `parentSession` | optional | `string` |
|
||||
| `version` | required | `3` |
|
||||
| `version` | required | `4` |
|
||||
|
||||
<a id="persistence-type-packagescoresessionsrctypestssurfaceop"></a>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
| 根类型 | 类别 | SHA-256 | 已解析类型 |
|
||||
|---|---|---|---|
|
||||
| `SessionHeader` | header | `2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03` | [`packages/core/session/src/types.ts#SessionHeader`](#persistence-type-packagescoresessionsrctypestssessionheader) |
|
||||
| `SessionHeader` | header | `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` | [`packages/core/session/src/types.ts#SessionHeader`](#persistence-type-packagescoresessionsrctypestssessionheader) |
|
||||
| `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`packages/session/session-persistence-jsonl/src/format.ts#HeaderLine`](#persistence-type-packagessessionsession-persistence-jsonlsrcformattsheaderline) |
|
||||
| `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`SessionEventEnvelope`](#persistence-type-sessioneventenvelope) |
|
||||
| `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`event:agent-preset/selected`](#persistence-type-eventagent-presetselected) |
|
||||
@@ -1270,13 +1270,13 @@ SHA-256: `a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80`
|
||||
|
||||
`2`
|
||||
|
||||
<a id="persistence-type-3"></a>
|
||||
<a id="persistence-type-4"></a>
|
||||
|
||||
### `3`
|
||||
### `4`
|
||||
|
||||
SHA-256: `137ac60e26bcb192210d9a32a704b73b0924ed34019323defa1b787be95bd45e`
|
||||
SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0`
|
||||
|
||||
`3`
|
||||
`4`
|
||||
|
||||
<a id="persistence-type-aborted"></a>
|
||||
|
||||
@@ -4562,7 +4562,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6`
|
||||
|
||||
### `packages/core/session/src/types.ts#SessionHeader`
|
||||
|
||||
SHA-256: `2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03`
|
||||
SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445`
|
||||
|
||||
来源:[`packages/core/session/src/types.ts:93`](../packages/core/session/src/types.ts)
|
||||
|
||||
@@ -4576,7 +4576,7 @@ SHA-256: `2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03`
|
||||
| `isSeeded` | 必需 | `boolean` |
|
||||
| `origin` | 可选 | `"subagent"` |
|
||||
| `parentSession` | 可选 | `string` |
|
||||
| `version` | 必需 | `3` |
|
||||
| `version` | 必需 | `4` |
|
||||
|
||||
<a id="persistence-type-packagescoresessionsrctypestssurfaceop"></a>
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-16-session-format-v4.md
|
||||
2026-09-16-session-format-v4.md: 57273fba4d45ff77537a4d5777f182eef406e156
|
||||
2026-09-16-session-format-v4.zh.md: 26c15f688b0f68974ed07c720b1e4c1b168f1a6c
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: "Records a persistence type transition and its compatibility acknowledgement."
|
||||
kind: persistence-change
|
||||
---
|
||||
|
||||
# 2026-09-16-session-format-v4
|
||||
|
||||
English | [中文](2026-09-16-session-format-v4.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Advances the declared SessionHeader.version from 3 to 4 for the V4 integration writer. No declared event payload or envelope type changes.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Declaration](#declaration)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Verification](#verification)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
<a id="declaration"></a>
|
||||
## Declaration
|
||||
|
||||
```yaml persistence-change
|
||||
schemaVersion: 1
|
||||
id: 2026-09-16-session-format-v4
|
||||
baseline: false
|
||||
changes:
|
||||
- root: "SessionHeader"
|
||||
previous: "2026-09-11-initial"
|
||||
after: "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445"
|
||||
decision: version-bump
|
||||
```
|
||||
|
||||
<a id="compatibility"></a>
|
||||
## Compatibility
|
||||
|
||||
The [V3-to-V4 migration](../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) advances the header and preserves every admitted event and inherited cut. Its generation-aware delivery checks prevent historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation; the catalog restores supported older inputs through the complete adjacent chain. The [V3 schema reference](historical-formats/v3.md) preserves the outgoing declarations, and persistence retains committed predecessor bytes while publishing only the current successor.
|
||||
|
||||
<a id="verification"></a>
|
||||
## Verification
|
||||
|
||||
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests` passed 22 tests across two files, covering identity preservation, inherited cuts, admission, and delivery-generation refusal. `pnpm run verify-persistence-formats` passed for all five references from V0 through V4. Before the writer change, `pnpm run verify-persistence-catalog` passed and `pnpm run verify-persistence-formats --archive 3` captured the V3 schema.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## Dev Note
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"roots": [
|
||||
{
|
||||
"key": "SessionHeader",
|
||||
"kind": "header",
|
||||
"digest": "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445",
|
||||
"schema": {
|
||||
"root": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"kind": "object",
|
||||
"properties": [
|
||||
{
|
||||
"name": "agentPreset",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "createdAt",
|
||||
"type": 2,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "cwd",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "delegationDepth",
|
||||
"type": 2,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"type": 1,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "isSeeded",
|
||||
"type": 3,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "origin",
|
||||
"type": 4,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "parentSession",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"type": 5,
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"indices": []
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": "subagent"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"types": []
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: "记录持久化类型更改及其兼容性确认。"
|
||||
kind: persistence-change
|
||||
---
|
||||
|
||||
# 2026-09-16-session-format-v4
|
||||
|
||||
[English](2026-09-16-session-format-v4.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
将声明的 SessionHeader.version 从 3 推进到 4,用于 V4 集成写入器。事件载荷与信封的声明类型均未变化。
|
||||
|
||||
## 目录
|
||||
|
||||
- [声明](#declaration)
|
||||
- [兼容性](#compatibility)
|
||||
- [验证](#verification)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
<a id="declaration"></a>
|
||||
## 声明
|
||||
|
||||
```yaml persistence-change
|
||||
schemaVersion: 1
|
||||
id: 2026-09-16-session-format-v4
|
||||
baseline: false
|
||||
changes:
|
||||
- root: "SessionHeader"
|
||||
previous: "2026-09-11-initial"
|
||||
after: "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445"
|
||||
decision: version-bump
|
||||
```
|
||||
|
||||
<a id="compatibility"></a>
|
||||
## 兼容性
|
||||
|
||||
[V3 到 V4 迁移](../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)推进 header,并保留每个已接纳事件与继承切点。其按 generation 校验 delivery 的规则可防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation;目录通过完整的相邻迁移链恢复受支持的旧输入。[V3 schema 参考](historical-formats/v3.zh.md)保留原声明,持久化在只发布当前后继代的同时保留已提交前代的字节。
|
||||
|
||||
<a id="verification"></a>
|
||||
## 验证
|
||||
|
||||
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests` 的两个文件共 22 个测试通过,覆盖恒等保留、继承切点、准入与 delivery generation 拒绝。`pnpm run verify-persistence-formats` 对从 V0 到 V4 的全部五个参考均验证通过。写入器变更前,`pnpm run verify-persistence-catalog` 通过,且 `pnpm run verify-persistence-formats --archive 3` 已捕获 V3 schema。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## 开发备注
|
||||
|
||||
无。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/README.md
|
||||
README.md: 35fa4cec9c960e8d0e047c42e35dad83992ac8d0
|
||||
README.zh.md: 7dd2af55d1d68c108d678e6e0849f7d679da929d
|
||||
README.md: 35b09b11b41f80bfe138829099ceff98123ca4bc
|
||||
README.zh.md: 409bfe99922b5689321072c75cfd599f75ad96f3
|
||||
@@ -31,7 +31,8 @@ The index is generated from validated snapshots and the current writer constant.
|
||||
| 0 | `dsh-v0.1.2-rc.1` | [V0](v0.md) | [JSON](v0.schema.json) | 54 / 415 |
|
||||
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
|
||||
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
|
||||
| 3 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
|
||||
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
|
||||
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
|
||||
|
||||
<!-- persistence-format-index:end -->
|
||||
|
||||
@@ -40,7 +41,7 @@ The index is generated from validated snapshots and the current writer constant.
|
||||
|
||||
Each `vN.md` / `vN.zh.md` pair has `kind: persistence-format`, an identical `yaml persistence-format` declaration binding every root key to its captured digest, a pairing sidecar, and a complete `vN.schema.json`. The [template](../../../.agents/skills/dsh-doc/templates/persistence-format.md) defines these records. Roots cover the logical Session header, physical JSONL header, event envelope, and all first-party events at the selected checkpoint; each root includes every reachable declared type. Packed physical body records have separate codec owners linked from each page.
|
||||
|
||||
V0 and V2 use the latest matching tags in the [captured prerelease archive](../releases/README.md). V1 uses an intermediate source tree identified in its reference because the captured tags contain no V1 writer. Historical sources retain file paths without line numbers. Snapshots preserve historical optional fields and opaque values; they do not substitute current types into older formats. Historical identifiers remain intact only in schema JSON and the verified generated schema regions; authored prose follows current terminology rules.
|
||||
V0 and V2 use the latest matching tags in the [captured prerelease archive](../releases/README.md). V1 uses an intermediate source tree identified in its reference because the captured tags contain no V1 writer. V3 captures the verified inventory before the V4 writer change in PR #4320. Historical sources retain file paths without line numbers. Snapshots preserve historical optional fields and opaque values; they do not substitute current types into older formats. Historical identifiers remain intact only in schema JSON and the verified generated schema regions; authored prose follows current terminology rules.
|
||||
|
||||
These references describe selected schemas, not historical application replay or migration safety. Same-version event additions and optional payload changes can produce other valid inventories. The prerelease archive retains tag-by-tag differences; [change records](../README.md) retain current compatibility acknowledgements. Neither history is replaced by these format snapshots.
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
|
||||
| 0 | `dsh-v0.1.2-rc.1` | [V0](v0.zh.md) | [JSON](v0.schema.json) | 54 / 415 |
|
||||
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
|
||||
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
|
||||
| 3 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
|
||||
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
|
||||
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
|
||||
|
||||
<!-- persistence-format-index:end -->
|
||||
|
||||
@@ -40,7 +41,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
|
||||
|
||||
每组 `vN.md` / `vN.zh.md` 文档包含 `kind: persistence-format`、将每个根类型键绑定到已记录摘要的相同 `yaml persistence-format` 声明、配对记录及完整的 `vN.schema.json`。[模板](../../../.agents/skills/dsh-doc/templates/persistence-format.md)定义这些记录。根类型覆盖所选检查点的逻辑 Session header、物理 JSONL header、事件信封与所有第一方事件;每个根包含所有可达的声明类型。打包的物理 body 记录由每页链接的独立 codec 负责。
|
||||
|
||||
V0 与 V2 使用[已收录预发布归档](../releases/README.zh.md)中最后一个对应 tag。由于收录的 tag 没有 V1 写入器,V1 使用其参考文档中标识的中间源码树。历史来源保留文件路径,不包含行号。快照保留历史可选字段与不透明值,不会将当前类型替换到旧格式中。历史标识符仅在 schema JSON 与已验证的生成 schema 区间中原样保留;人工说明遵循当前术语规则。
|
||||
V0 与 V2 使用[已收录预发布归档](../releases/README.zh.md)中最后一个对应 tag。由于收录的 tag 没有 V1 写入器,V1 使用其参考文档中标识的中间源码树。V3 捕获 PR #4320 中 V4 写入器变更前已验证的目录。历史来源保留文件路径,不包含行号。快照保留历史可选字段与不透明值,不会将当前类型替换到旧格式中。历史标识符仅在 schema JSON 与已验证的生成 schema 区间中原样保留;人工说明遵循当前术语规则。
|
||||
|
||||
这些参考描述选定的 schema,不表示历史应用回放结果或迁移安全性。同版本内新增事件与可选载荷变更可以产生其他有效目录。预发布归档保留逐 tag 差异;[变更记录](../README.zh.md)保留当前兼容性确认。这些格式快照不替代任何一种历史。
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/v3.md
|
||||
v3.md: cf9d6d64b08e4e861bdb20e244e1e5d75e297323
|
||||
v3.zh.md: b49cbf23a9c66fbe2804e1f2a28ac4e5cfcd7441
|
||||
File diff suppressed because it is too large.
Load diff
+61307
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Generated
+101
-101
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"key": "SessionHeader",
|
||||
"kind": "header",
|
||||
"digest": "2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03",
|
||||
"digest": "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445",
|
||||
"schema": {
|
||||
"root": 0,
|
||||
"nodes": [
|
||||
@@ -77,7 +77,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 3
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17631,20 +17631,6 @@
|
||||
"packages/llm/llm/src/types.ts:78"
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": "137ac60e26bcb192210d9a32a704b73b0924ed34019323defa1b787be95bd45e",
|
||||
"schema": {
|
||||
"root": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"names": [],
|
||||
"sources": []
|
||||
},
|
||||
{
|
||||
"digest": "13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176",
|
||||
"schema": {
|
||||
@@ -18228,6 +18214,91 @@
|
||||
"names": [],
|
||||
"sources": []
|
||||
},
|
||||
{
|
||||
"digest": "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445",
|
||||
"schema": {
|
||||
"root": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"kind": "object",
|
||||
"properties": [
|
||||
{
|
||||
"name": "agentPreset",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "createdAt",
|
||||
"type": 2,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "cwd",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "delegationDepth",
|
||||
"type": 2,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"type": 1,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "isSeeded",
|
||||
"type": 3,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "origin",
|
||||
"type": 4,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "parentSession",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"type": 5,
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"indices": []
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": "subagent"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
"names": [
|
||||
"packages/core/session/src/types.ts#SessionHeader"
|
||||
],
|
||||
"sources": [
|
||||
"packages/core/session/src/types.ts:93"
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": "1ab2ef1296f157ba96998f179bb5daa9d21a82480da2901127aeab2730797c92",
|
||||
"schema": {
|
||||
@@ -23340,91 +23411,6 @@
|
||||
],
|
||||
"sources": []
|
||||
},
|
||||
{
|
||||
"digest": "2a8b114637e66faab5fae1c6e2fba65320db6224c80f5f36c5bf32f937154d03",
|
||||
"schema": {
|
||||
"root": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"kind": "object",
|
||||
"properties": [
|
||||
{
|
||||
"name": "agentPreset",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "createdAt",
|
||||
"type": 2,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "cwd",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "delegationDepth",
|
||||
"type": 2,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"type": 1,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "isSeeded",
|
||||
"type": 3,
|
||||
"optional": false
|
||||
},
|
||||
{
|
||||
"name": "origin",
|
||||
"type": 4,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "parentSession",
|
||||
"type": 1,
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"type": 5,
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"indices": []
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"kind": "primitive",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": "subagent"
|
||||
},
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
"names": [
|
||||
"packages/core/session/src/types.ts#SessionHeader"
|
||||
],
|
||||
"sources": [
|
||||
"packages/core/session/src/types.ts:93"
|
||||
]
|
||||
},
|
||||
{
|
||||
"digest": "2ab12a0e345658f9342816d773fe610f049982bcad82631f43b9205abe43e5ee",
|
||||
"schema": {
|
||||
@@ -49188,6 +49174,20 @@
|
||||
"names": [],
|
||||
"sources": []
|
||||
},
|
||||
{
|
||||
"digest": "af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0",
|
||||
"schema": {
|
||||
"root": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"kind": "literal",
|
||||
"value": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
"names": [],
|
||||
"sources": []
|
||||
},
|
||||
{
|
||||
"digest": "aff60ef947c18b012644852bdd0d5bfe72065e061dd563787c76c646b4a10f6b",
|
||||
"schema": {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/session/README.md
|
||||
README.md: 8d07292287d7300b562751ad74de77397bd5b5db
|
||||
README.zh.md: 28b6f1f02838420861a76409046c0e9a12bac1b5
|
||||
README.md: 3e120780c711a3e35088291ea33b883c9a3653c6
|
||||
README.zh.md: 6dbb593e04a9e153693bf32533747a163c71d7cf
|
||||
@@ -183,7 +183,7 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
|
||||
These limits define when the session store needs special care. They are current package constraints, not a task backlog.
|
||||
|
||||
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the fork API.
|
||||
- **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the adjacent migration chain converts supported history before constructing `Session`, and the write-open path publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
|
||||
- **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the [adjacent migration chain](../../session/session-format-catalog/README.md) converts supported history before constructing `Session`, and the write-open path publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
|
||||
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
|
||||
- **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ session.deriveMessages() // the derived model history
|
||||
这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
|
||||
|
||||
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;fork API 不支持对已持久化但未加载的会话进行 fork。
|
||||
- **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;相邻迁移链在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
|
||||
- **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;[相邻迁移链](../../session/session-format-catalog/README.zh.md)在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
|
||||
- **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
|
||||
- **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export type OptionalSessionSeq = SessionSeq | null
|
||||
* immutable prior-generation, and current fast-path rules are recorded in
|
||||
* `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
|
||||
*/
|
||||
export const SESSION_FORMAT_VERSION = 3
|
||||
export const SESSION_FORMAT_VERSION = 4
|
||||
|
||||
/**
|
||||
* Immutable validated storage metadata, kept outside the conversation event log.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import {
|
||||
generationLogFilename,
|
||||
scanLog,
|
||||
@@ -37,6 +38,14 @@ function readSession(id: string): ReturnType<typeof scanLog> {
|
||||
return scanLog(Buffer.from(generated))
|
||||
}
|
||||
|
||||
function restoreSession(content: string, version: number) {
|
||||
const [header, ...events] = content.trimEnd().split('\n').map(line => JSON.parse(line) as unknown)
|
||||
expect(header).toMatchObject({ type: 'session', version })
|
||||
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
|
||||
for (const event of events) restore.decodeRow(event)
|
||||
return restore.finish()
|
||||
}
|
||||
|
||||
function textOf(event: SessionEvent): string {
|
||||
if (event.type === 'user/message') {
|
||||
return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
|
||||
@@ -48,17 +57,27 @@ function textOf(event: SessionEvent): string {
|
||||
}
|
||||
|
||||
describe('WebWorker preview VFS example', () => {
|
||||
it('matches its deterministic source byte for byte', () => {
|
||||
it('retains V3 inputs that strictly restore to the deterministic current output', () => {
|
||||
const expected = buildVfsExampleFiles()
|
||||
const currentName = generationLogFilename(SESSION_FORMAT_VERSION, 'none')
|
||||
const committedPath = (path: string): string => path.endsWith(`/${currentName}`)
|
||||
? `${path.slice(0, -currentName.length)}session.v3.jsonl`
|
||||
: path
|
||||
const predecessors = Object.values(VFS_EXAMPLE_SESSION_IDS)
|
||||
.map(id => `home/sessions/--dsh-workspace--/${id}/session.v2.jsonl`)
|
||||
expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([...expected.keys(), ...predecessors].sort())
|
||||
expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([...expected.keys()].map(committedPath).concat(predecessors).sort())
|
||||
for (const [path, content] of expected) {
|
||||
expect(readFileSync(join(VFS_EXAMPLE_ROOT, path), 'utf8'), path).toBe(content)
|
||||
if (path === 'home/storages/session_projcache.json') continue
|
||||
const committed = readFileSync(join(VFS_EXAMPLE_ROOT, committedPath(path)), 'utf8')
|
||||
if (path.endsWith(`/${currentName}`)) {
|
||||
expect(restoreSession(committed, 3), path).toEqual(restoreSession(content, SESSION_FORMAT_VERSION))
|
||||
} else {
|
||||
expect(committed, path).toBe(content)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the committed cache on the current generated projection', () => {
|
||||
it('retains V3 cache identity while the generated cache uses the current writer', () => {
|
||||
const committed = JSON.parse(readFileSync(
|
||||
join(VFS_EXAMPLE_ROOT, 'home/storages/session_projcache.json'),
|
||||
'utf8',
|
||||
@@ -80,9 +99,12 @@ describe('WebWorker preview VFS example', () => {
|
||||
const generatedText = buildVfsExampleFiles().get('home/storages/session_projcache.json')
|
||||
if (generatedText === undefined) throw new Error('missing generated VFS example projection cache')
|
||||
const generated = JSON.parse(generatedText) as typeof committed
|
||||
expect(committed).toEqual(generated)
|
||||
expect(committed.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]?.identity.formatVersion)
|
||||
.toBe(SESSION_FORMAT_VERSION)
|
||||
const expectedCurrent = structuredClone(committed)
|
||||
for (const session of Object.values(expectedCurrent.tables.sessions)) {
|
||||
expect(session.identity.formatVersion).toBe(3)
|
||||
session.identity.formatVersion = SESSION_FORMAT_VERSION
|
||||
}
|
||||
expect(generated).toEqual(expectedCurrent)
|
||||
expect(generated.unit).toEqual({
|
||||
name: projectionCacheDomainSpec.name,
|
||||
version: projectionCacheDomainSpec.version,
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 86d3c2ce9467fda0cdc6cc2ee255be75960e7a45
|
||||
README.zh.md: f7b0ea006e2cba24e727bd6f2860583778a9d6ab
|
||||
README.md: 989a98f4b89ebf9ef66a05499e003bd13b6532ee
|
||||
README.zh.md: 4f9a553188b6f49e0bafc6222581e147d8132a2d
|
||||
@@ -31,6 +31,8 @@ The group splits into four families: durable storage (persistence seam, backends
|
||||
| [`session-format/`](session-format/README.md) | Pure adjacent-format chain and artifact validation library | library — no ctx key |
|
||||
| [`session-format-v0-to-v1/`](session-format-v0-to-v1/README.md) | Frozen released-v0 decoder and identity migration into released v1 | library — no ctx key |
|
||||
| [`session-format-v1-to-v2/`](session-format-v1-to-v2/README.md) | Frozen released-v1 decoder and cardinality-changing Assistant-stream migration into released v2 | library — no ctx key |
|
||||
| [`session-format-v2-to-v3/`](session-format-v2-to-v3/README.md) | Frozen V2 decoder and system-prompt, envelope, and PTC migration into V3 | library — no ctx key |
|
||||
| [`session-format-v3-to-v4/`](session-format-v3-to-v4/README.md) | V3 identity migration and V4 codec with generation-aware delivery validation | library — no ctx key |
|
||||
| [`session-format-catalog/`](session-format-catalog/README.md) | Generated static catalog of shipped adjacent migrations | library — no ctx key |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Defines the durable session-storage service and the shared write coordination every backend composes | `ctx.sessionPersistence` |
|
||||
| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | Shipped backend: immutable canonical generation filenames per Session with exclusive successor publication, optionally Zstandard-compressed | registers on `ctx.sessionPersistence` |
|
||||
|
||||
@@ -31,6 +31,8 @@ session 组让对话持久保存,恢复已发布的日志格式,并使已提
|
||||
| [`session-format/`](session-format/README.zh.md) | 纯相邻格式链与产物校验库 | 库,不使用 ctx key |
|
||||
| [`session-format-v0-to-v1/`](session-format-v0-to-v1/README.zh.md) | 冻结的 released-v0 解码器,以及到 released v1 的恒等迁移 | 库,不使用 ctx key |
|
||||
| [`session-format-v1-to-v2/`](session-format-v1-to-v2/README.zh.md) | 冻结的 released-v1 解码器,以及迁移到 released v2 时会改变基数的 Assistant 流迁移 | 库,不使用 ctx key |
|
||||
| [`session-format-v2-to-v3/`](session-format-v2-to-v3/README.zh.md) | 冻结的 V2 解码器,以及迁入 V3 的系统提示词、信封与 PTC 转换 | 库——无 ctx 键 |
|
||||
| [`session-format-v3-to-v4/`](session-format-v3-to-v4/README.zh.md) | V3 恒等迁移与 V4 编解码器,按 generation 校验 delivery | 库——无 ctx 键 |
|
||||
| [`session-format-catalog/`](session-format-catalog/README.zh.md) | 自动生成的已交付相邻迁移静态目录 | 库,不使用 ctx key |
|
||||
| [`session-persistence/`](session-persistence/README.zh.md) | 定义持久会话存储服务,以及每个后端组合的共享写入协调机制 | `ctx.sessionPersistence` |
|
||||
| [`session-persistence-jsonl/`](session-persistence-jsonl/README.zh.md) | 随产品交付的后端:逐 Session 使用不可变规范 generation 文件名并排他发布后继;可选 Zstandard 压缩 | 注册到 `ctx.sessionPersistence` |
|
||||
|
||||
@@ -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: 8232a28b2c2c31c0176adb62e07ed16a804791e8
|
||||
README.zh.md: 2c4c86da50640f672842d98e6cc254dca08a3470
|
||||
README.md: 5ebfa82486873810f64d2f7bd3f83957bd995ed9
|
||||
README.zh.md: 6e8d674b0dfd2e69c2ff2d0857afd04ab91734b5
|
||||
@@ -67,6 +67,7 @@ The catalog contains all supported historical readers directly. A profile cannot
|
||||
- [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 specification](../session-format-v2-to-v3/README.md#v2-to-v3-specification) — transformations, preservation, and refusal.
|
||||
- [V3 to V4 specification](../session-format-v3-to-v4/README.md#v3-to-v4-specification) — identity conversion and delivery-generation validation.
|
||||
- [JSONL persistence](../session-persistence-jsonl/README.md) — immutable generation naming and exclusive publication.
|
||||
|
||||
-----
|
||||
|
||||
@@ -67,6 +67,7 @@ Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transform
|
||||
- [已发布 v0 到 v1 迁移边](../session-format-v0-to-v1/README.zh.md)——编解码器与校验器所有权。
|
||||
- [已发布 v1 到 v2 迁移边](../session-format-v1-to-v2/README.zh.md)——Assistant 流嵌入与基数变化引用重映射。
|
||||
- [已发布 V2 到 V3 规范](../session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)——转换、保留与拒绝。
|
||||
- [V3 到 V4 规范](../session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)——恒等转换与 delivery generation 校验。
|
||||
- [JSONL 持久化](../session-persistence-jsonl/README.zh.md)——不可变 generation 命名与排他发布。
|
||||
|
||||
-----
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-v1-to-v2": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-v2-to-v3": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction-image-offload": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -8,24 +8,36 @@ import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
|
||||
import { validateInstalledCurrentSessionArtifact, validateInstalledCurrentSessionHeader } from './current.ts'
|
||||
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
|
||||
import { releasedV2SessionFormatCodec, sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
|
||||
import { assertReleasedV3Header, releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
import { releasedV3SessionFormatCodec, sessionFormatV2ToV3 } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
import { assertReleasedV4Header, releasedV4SessionFormatCodec, restoreReleasedV4Artifact, sessionFormatV3ToV4 } from '@deepseek-ai/dsh-session-format-v3-to-v4'
|
||||
|
||||
/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */
|
||||
export const sessionFormatCatalog = createSessionFormatCatalog({
|
||||
currentVersion: 3,
|
||||
codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec],
|
||||
currentEncoder: releasedV3SessionFormatCodec,
|
||||
migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3],
|
||||
currentVersion: 4,
|
||||
codecs: [
|
||||
releasedV0SessionFormatCodec,
|
||||
releasedV1SessionFormatCodec,
|
||||
releasedV2SessionFormatCodec,
|
||||
releasedV3SessionFormatCodec,
|
||||
releasedV4SessionFormatCodec,
|
||||
],
|
||||
currentEncoder: releasedV4SessionFormatCodec,
|
||||
migrations: [
|
||||
sessionFormatV0ToV1,
|
||||
sessionFormatV1ToV2,
|
||||
sessionFormatV2ToV3,
|
||||
sessionFormatV3ToV4,
|
||||
],
|
||||
restoreCurrent(artifact) {
|
||||
const restored = restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
|
||||
const restored = restoreReleasedV4Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
|
||||
validateInstalledCurrentSessionArtifact(restored)
|
||||
return restored
|
||||
},
|
||||
restoreTransformedCurrent(artifact) {
|
||||
return restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
|
||||
return restoreReleasedV4Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
|
||||
},
|
||||
restoreCurrentHeader(header) {
|
||||
assertReleasedV3Header(header)
|
||||
assertReleasedV4Header(header)
|
||||
validateInstalledCurrentSessionHeader(header)
|
||||
return header
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sessionFormatCatalog } from '../src/index.ts'
|
||||
import { currentSessionMessageProjections } from '../src/message-projections.ts'
|
||||
import { MESSAGE_PROJECTION_EVENT_TYPES } from '@deepseek-ai/dsh-session/src/known-event-types.ts'
|
||||
import { validateInstalledCurrentSessionArtifact } from '../src/current.ts'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
@@ -27,7 +27,7 @@ describe('first-party Session format catalog', () => {
|
||||
}
|
||||
expect(() => { validateInstalledCurrentSessionArtifact(artifact) }).toThrow(/image index 0 does not exist/)
|
||||
})
|
||||
it('statically owns the complete adjacent v0 to v3 chain', () => {
|
||||
it('statically owns every adjacent edge through the current writer', () => {
|
||||
const header = {
|
||||
type: 'session',
|
||||
version: 0,
|
||||
@@ -37,13 +37,13 @@ describe('first-party Session format catalog', () => {
|
||||
delegationDepth: 0,
|
||||
}
|
||||
|
||||
expect(sessionFormatCatalog.currentVersion).toBe(3)
|
||||
expect(sessionFormatCatalog.currentVersion).toBe(SESSION_FORMAT_VERSION)
|
||||
expect(sessionFormatCatalog.readHeader(header)).toEqual({
|
||||
status: 'migration-required',
|
||||
storedVersion: 0,
|
||||
targetVersion: 3,
|
||||
targetVersion: SESSION_FORMAT_VERSION,
|
||||
header: {
|
||||
version: 3,
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: 'catalog',
|
||||
createdAt: 1,
|
||||
isSeeded: true,
|
||||
@@ -57,13 +57,13 @@ describe('first-party Session format catalog', () => {
|
||||
})
|
||||
restore.decodeRow({ type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } })
|
||||
expect(restore.finish()).toMatchObject({
|
||||
header: { version: 3, id: 'catalog' },
|
||||
header: { version: SESSION_FORMAT_VERSION, id: 'catalog' },
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the installed current vocabulary without freezing ordinary payload additions', () => {
|
||||
const header = {
|
||||
type: 'session', version: 3, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
|
||||
type: 'session', version: SESSION_FORMAT_VERSION, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
|
||||
}
|
||||
const restore = (rows: readonly unknown[]) => {
|
||||
const current = sessionFormatCatalog.createRestore(header, {
|
||||
@@ -102,7 +102,7 @@ describe('first-party Session format catalog', () => {
|
||||
restore.decodeRow({ type: 'feedback/record', seq: 0, time: 1, data: { text: 'inherited' } })
|
||||
}
|
||||
const artifact = restore.finish()
|
||||
expect(artifact.header.version).toBe(3)
|
||||
expect(artifact.header.version).toBe(SESSION_FORMAT_VERSION)
|
||||
expect(artifact.inheritedEventCount).toBe(seedLength)
|
||||
expect(artifact.events.at(-1)).toEqual({
|
||||
type: 'session/end-seed', seq: seedLength, time: 1, data: { inherited: true },
|
||||
@@ -123,7 +123,7 @@ describe('first-party Session format catalog', () => {
|
||||
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
|
||||
for (const row of rows) restore.decodeRow(row)
|
||||
expect(restore.finish()).toEqual({
|
||||
header: { version: 3, id: 'v2-identity', createdAt: 1, isSeeded, delegationDepth: 0 },
|
||||
header: { version: SESSION_FORMAT_VERSION, id: 'v2-identity', createdAt: 1, isSeeded, delegationDepth: 0 },
|
||||
inheritedEventCount: isSeeded ? 4 : 0,
|
||||
events: [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
@@ -200,7 +200,7 @@ describe('first-party Session format catalog', () => {
|
||||
{ ...rows[8], seq: 9 }, { ...rows[9], seq: 10 },
|
||||
]
|
||||
expect(artifact).toEqual({
|
||||
header: { version: 3, id: sourceHeader.id, createdAt: 1, isSeeded: false, delegationDepth: 0 },
|
||||
header: { version: SESSION_FORMAT_VERSION, id: sourceHeader.id, createdAt: 1, isSeeded: false, delegationDepth: 0 },
|
||||
inheritedEventCount: 0, events: expected,
|
||||
})
|
||||
const currentHeader = deepFreeze(sessionFormatCatalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount))
|
||||
@@ -213,7 +213,7 @@ describe('first-party Session format catalog', () => {
|
||||
expect(JSON.stringify({ currentHeader, currentRows })).toBe(encodedBefore)
|
||||
})
|
||||
|
||||
it.each(['current', 'transformed'] as const)('rejects native v3 obsolete required tags with %s validation and retains ignorable tags', (validation) => {
|
||||
it.each(['current', 'transformed'] as const)('rejects obsolete required tags in V3 input with %s validation and retains ignorable tags', (validation) => {
|
||||
const header = deepFreeze({ type: 'session', version: 3, id: 'native-ptc', createdAt: 1, isSeeded: false, delegationDepth: 0 })
|
||||
for (const type of ['tool/code-dispatch-start', 'tool/code-dispatch']) {
|
||||
const required = deepFreeze({ type, seq: 0, time: 1, data: null })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionFormatArtifact, SessionFormatHeader } from '@deepseek-ai/dsh-session-format'
|
||||
import {
|
||||
validateInstalledCurrentSessionArtifact,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
} from '../src/current.ts'
|
||||
|
||||
const currentHeader: SessionFormatHeader = {
|
||||
version: 3,
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: 'installed-current',
|
||||
createdAt: 1,
|
||||
isSeeded: false,
|
||||
@@ -16,14 +17,14 @@ const currentHeader: SessionFormatHeader = {
|
||||
describe('installed current Session restoration', () => {
|
||||
it('rejects version skew before entering current Session validation', () => {
|
||||
expect(() => { validateInstalledCurrentSessionHeader({ ...currentHeader, version: 0 }) })
|
||||
.toThrow(/installed Session format is v3, got v0/)
|
||||
.toThrow(`installed Session format is v${SESSION_FORMAT_VERSION}, got v0`)
|
||||
const artifact: SessionFormatArtifact = {
|
||||
header: { ...currentHeader, version: 0 },
|
||||
inheritedEventCount: 0,
|
||||
events: [],
|
||||
}
|
||||
expect(() => { validateInstalledCurrentSessionArtifact(artifact) })
|
||||
.toThrow(/installed Session format is v3, got v0/)
|
||||
.toThrow(`installed Session format is v${SESSION_FORMAT_VERSION}, got v0`)
|
||||
})
|
||||
|
||||
it('accepts only current request-header reasons and the true starts-series marker', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Every historical entry generation migrates all preset selections before projection or fork. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import { sessionFormatCatalog } from '../src/index.ts'
|
||||
|
||||
describe('catalog preset migration', () => {
|
||||
@@ -21,7 +22,7 @@ describe('catalog preset migration', () => {
|
||||
const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
|
||||
for (const row of source) restore.decodeRow(row)
|
||||
const artifact = restore.finish()
|
||||
expect(artifact.header).toMatchObject({ version: 3, id: 'code', agentPreset: 'ptc', isSeeded: true })
|
||||
expect(artifact.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'code', agentPreset: 'ptc', isSeeded: true })
|
||||
expect(artifact.inheritedEventCount).toBe(2)
|
||||
expect(artifact.events.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
expect(artifact.events.filter(event => event.type === 'agent-preset/selected').map(event => event.data))
|
||||
@@ -30,7 +31,7 @@ describe('catalog preset migration', () => {
|
||||
expect(JSON.stringify({ header, source })).toBe(before)
|
||||
})
|
||||
|
||||
it('does not reinterpret a native v3 custom preset named code', () => {
|
||||
it('does not reinterpret a V3 custom preset named code', () => {
|
||||
const header = {
|
||||
type: 'session', version: 3, id: 'native', createdAt: 1, isSeeded: false,
|
||||
delegationDepth: 0, agentPreset: 'code',
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../session-format-v2-to-v3"
|
||||
},
|
||||
{
|
||||
"path": "../session-format-v3-to-v4"
|
||||
},
|
||||
{
|
||||
"path": "../../compaction/compaction-image-offload"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Fixed V0–V3 composition for released-edge tests, independent of the current writer. */
|
||||
|
||||
import { KNOWN_SESSION_EVENT_TYPES } 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 { releasedV2SessionFormatCodec, sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
|
||||
import { assertReleasedV3Header, releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts'
|
||||
|
||||
/** Restore the released V3 result through its complete predecessor chain. */
|
||||
export const v3Catalog = createSessionFormatCatalog({
|
||||
currentVersion: 3,
|
||||
codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec],
|
||||
currentEncoder: releasedV3SessionFormatCodec,
|
||||
migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3],
|
||||
restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES),
|
||||
restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES),
|
||||
restoreCurrentHeader(header) {
|
||||
assertReleasedV3Header(header)
|
||||
return header
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import { v3Catalog as sessionFormatCatalog } from './catalog.ts'
|
||||
import { SessionFormatEventCollector, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import { releasedV3SessionFormatCodec, restoreReleasedV3Artifact } from '../src/index.ts'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
|
||||
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import { v3Catalog as sessionFormatCatalog } from './catalog.ts'
|
||||
import { restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts'
|
||||
|
||||
const header = { version: 2, id: 'content-admission', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md
|
||||
README.md: 97befb074bd6b756905368a88b621e4871d1ed0f
|
||||
README.zh.md: 169ba020026d63e5d79bd2ac362968fff08cccfa
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
description: "Restore V3 Sessions as V4 while preserving events, inherited cuts, and recorded delivery generations."
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-session-format-v3-to-v4
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Restore supported V3 Sessions as V4 without changing their events or historical requests. The library advances the header version, retains event coordinates and inherited cuts, and prevents historical delivery markers from becoming active V4 watermarks. Persistence consumes it through the static catalog; the library does not read or publish files.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [V3-to-V4 specification](#v3-to-v4-specification)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Further Exploration](#further-exploration)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Use the [catalog](../session-format-catalog/README.md) for complete restoration. Direct imports serve catalog assembly and tests; this library has no Cordis mount configuration. Its [public exports](src/index.ts) provide the adjacent migration, the exact released V3 codec, the V4 codec, and target validators.
|
||||
|
||||
The header-only operation advances validated metadata without reading the body:
|
||||
|
||||
```text
|
||||
const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader)
|
||||
```
|
||||
|
||||
Complete restoration decodes rows, passes them through a fresh stage, and validates the target artifact. Partial stage emissions do not establish success: a later event or `finish()` can refuse the artifact. Strict catalog restoration uses `{ recovery: 'strict', validation: 'current' }`; the [format protocol](../session-format/README.md) owns scheduling and error handling.
|
||||
|
||||
-----
|
||||
|
||||
<a id="v3-to-v4-specification"></a>
|
||||
## V3-to-V4 specification
|
||||
|
||||
The logical header changes only `version: 3` to `version: 4`. Every admitted event retains its type, payload, timestamp, sequence, message identity, surface operation, and references. The stage emits the original event object. It adds, removes, and reorders no events. The released V3 codec remains owned by [V2-to-V3](../session-format-v2-to-v3/README.md); this package reuses it without changing earlier migration semantics.
|
||||
|
||||
Source events must be dense from zero. A seeded Session's last `session/end-seed` carrying `inherited: true` determines its inherited event count, excluding the marker. A supplied source cut must agree. Missing seeded markers and inherited markers in unseeded Sessions are refused. Each stage owns its counters and cut; seeded stages leave the cut unknown before EOF, including when preceding migrations change event count.
|
||||
|
||||
Complete restoration applies [native V3 admission](../session-format-v2-to-v3/README.md#native-v3-admission) to the unchanged representation: malformed canonical events and invalid relationships are refused; unknown required event types require installed vocabulary; unknown ignorable events retain their opaque values. This identity edge changes no coordinates or payload meaning, so retaining opaque data does not require interpreting it. The separate source-audit policies of earlier edges remain unchanged.
|
||||
|
||||
A `session-log-deepseek/delivery-accepted` event must identify a nonnegative safe-integer generation; omission identifies V0. A source marker claiming V4 is refused because advancing the header would activate it. Source V3 markers require a nonempty Session id and a `throughSeq` that names an earlier event. A foreign V3 marker is permitted only inside the inherited prefix of a Session with `parentSession`. Other generations retain their recorded coordinates and ids. No delivery payload is rewritten.
|
||||
|
||||
V4 restoration validates V4 delivery coordinates and ownership under the same rules. V3 deliveries are historical in V4 and do not become V4 watermarks. The V4 codec preserves V3 framing and pre-recovery structural refusal; complete vocabulary, delivery, and relationship checks require the target restorer or catalog `validation: 'current'`. Recoverable codec reads alone do not establish strict restoration.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The migration declaration creates independent streaming stages. Compact runs expand as iterables without an intermediate event array. The V4 codec translates only the physical header version while delegating released V3 event framing and recovery. JSONL scanners call `assertV4RowAdmission` before suppressing recoverable rows.
|
||||
|
||||
The target restorer uses a private V3 relationship view to distinguish V4 deliveries from historical V3 deliveries, then returns the original artifact. Its temporary generation substitutions never escape. No runtime invariant companion is published because this pure library owns no independently maintained runtime observations.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
- [Format version and release status](../../../docs/session-format-status.md) — checkout writer and published format authority.
|
||||
- [Adding a Session format version](../../../docs/cookbook/adding-a-session-format-version.md) — adjacent-edge integration and validation.
|
||||
- [JSONL persistence](../session-persistence-jsonl/README.md) — immutable generation selection and publication.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
### Historical restoration
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Historical requests retain their recorded messages and model configuration because the [identity stage](src/migration.ts) preserves `SessionFormatEvent.data`. This edge adds no model-visible content.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The identity conversion changes no request text or token-bearing data.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The edge preserves the recorded request prefix. Provider cache availability and eviction remain outside this library.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Existing V4 data skips this edge** — later edits to the unreleased migration do not transform an already-written V4 generation. Integration tests need fresh disposable homes populated from unchanged historical input.
|
||||
- **No file publication** — the library returns validated logical events; persistence owns publishing a successor beside unchanged committed generations.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
description: "将 V3 Session 恢复为 V4,保留事件、继承切分点和已记录的投递代际。"
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-session-format-v3-to-v4
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
将受支持的 V3 Session 恢复为 V4,不改变其中的事件或历史请求。该库推进头部版本,保留事件坐标和继承切分点,并防止历史投递标记成为生效的 V4 水位。持久化通过静态目录使用本库;本库不读取或发布文件。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [V3 到 V4 规范](#v3-to-v4-specification)
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [进一步探索](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与待办工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
完整恢复应使用[目录](../session-format-catalog/README.zh.md)。直接导入用于组装目录和测试;本库没有 Cordis 挂载配置。[公开导出](src/index.ts)提供相邻迁移、原样复用的已发布 V3 编解码器、V4 编解码器和目标校验器。
|
||||
|
||||
仅头部操作推进已校验的元数据,不读取正文:
|
||||
|
||||
```text
|
||||
const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader)
|
||||
```
|
||||
|
||||
完整恢复解码行、通过新的 Stage 处理行,并校验目标产物。Stage 的部分输出不代表恢复成功:后续事件或 `finish()` 仍可能拒绝该产物。严格的目录恢复使用 `{ recovery: 'strict', validation: 'current' }`;[格式协议](../session-format/README.zh.md)负责调度和错误处理。
|
||||
|
||||
-----
|
||||
|
||||
<a id="v3-to-v4-specification"></a>
|
||||
## V3 到 V4 规范
|
||||
|
||||
逻辑头部只将 `version: 3` 改为 `version: 4`。每个获准事件都保留其类型、载荷、时间戳、序号、消息身份、surface 操作和引用。Stage 输出原始事件对象,不增删或重排事件。已发布 V3 编解码器仍由 [V2 到 V3](../session-format-v2-to-v3/README.zh.md)拥有;本包复用它,不改变较早迁移的语义。
|
||||
|
||||
源事件必须从零开始连续编号。带种子的 Session 中,最后一个携带 `inherited: true` 的 `session/end-seed` 决定继承事件数,不包含该标记本身。传入的源切分点必须与之相符。带种子却缺少标记,或不带种子却出现继承标记的 Session 均被拒绝。每个 Stage 独立拥有计数器和切分点;带种子的 Stage 在 EOF 前保持切分点未知,包括前序迁移改变事件数的情况。
|
||||
|
||||
完整恢复对未改变的表示应用[原生 V3 准入规则](../session-format-v2-to-v3/README.zh.md#native-v3-admission):拒绝格式错误的规范事件和无效关系;未知必需事件类型要求已安装的事件词表支持;未知可忽略事件保留其不透明值。该恒等迁移不改变坐标或载荷含义,因此保留不透明数据不需要解释其内容。较早迁移边各自的源审计策略保持不变。
|
||||
|
||||
`session-log-deepseek/delivery-accepted` 事件必须标识非负安全整数代际;省略时表示 V0。声称属于 V4 的源标记会被拒绝,因为推进头部会使它生效。源 V3 标记要求非空 Session id,以及指向较早事件的 `throughSeq`。属于其他 Session 的 V3 标记只允许出现在带有 `parentSession` 的 Session 的继承前缀中。其他代际保留记录的坐标和 id。任何投递载荷都不会被重写。
|
||||
|
||||
V4 恢复按相同规则校验 V4 投递坐标和归属。V3 投递在 V4 中属于历史数据,不会成为 V4 水位。V4 编解码器保留 V3 行格式和恢复前的结构性拒绝规则;完整的词表、投递和关系校验需要目标恢复器或目录的 `validation: 'current'`。仅执行可恢复编解码读取不能证明严格恢复成功。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现内部机制 — 点击展开</summary>
|
||||
|
||||
迁移声明创建相互独立的流式 Stage。紧凑事件段通过迭代器展开,不生成中间事件数组。V4 编解码器只转换物理头部版本,将已发布 V3 事件行格式和恢复行为委托给原编解码器。JSONL 扫描器在抑制可恢复行之前调用 `assertV4RowAdmission`。
|
||||
|
||||
目标恢复器通过私有 V3 关系视图区分 V4 投递和历史 V3 投递,然后返回原始产物。临时的代际替换不会离开该视图。本包不发布运行时不变量伴随插件,因为这个纯函数库不拥有独立维护的运行时观测。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
- [格式版本与发布状态](../../../docs/session-format-status.zh.md) — 当前检出写入版本与已发布格式的权威记录。
|
||||
- [添加 Session 格式版本](../../../docs/cookbook/adding-a-session-format-version.zh.md) — 相邻迁移边的集成与校验。
|
||||
- [JSONL 持久化](../session-persistence-jsonl/README.zh.md) — 不可变代际选择与发布。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
### 历史恢复
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
历史请求保留记录的消息与模型配置,因为[恒等迁移 Stage](src/migration.ts)保留 `SessionFormatEvent.data`。该迁移边不添加模型可见内容。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
恒等转换不改变请求文本或承载 token 的数据。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
该迁移边保留记录的请求前缀。提供方缓存的可用性和淘汰策略不属于本库职责。
|
||||
|
||||
## 已知限制与待办工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **现有 V4 数据跳过此迁移边** — 对尚未发布迁移的后续修改不会转换已经写出的 V4 代际。集成测试需要使用新的临时 home,并从未改变的历史输入初始化。
|
||||
- **不发布文件** — 本库返回已校验的逻辑事件;持久化负责在不改变已提交代际的前提下发布后继文件。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作上下文 — 点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-format-v3-to-v4",
|
||||
"description": "Streaming identity migration and delivery validation from Session V3 to V4",
|
||||
"version": "0.1.6-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/session/session-format-v3-to-v4"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dsh": {
|
||||
"sessionFormatMigration": {
|
||||
"from": 3,
|
||||
"to": 4,
|
||||
"export": ".",
|
||||
"migration": "sessionFormatV3ToV4",
|
||||
"sourceCodec": "releasedV3SessionFormatCodec",
|
||||
"targetCodec": "releasedV4SessionFormatCodec",
|
||||
"targetHeaderValidator": "assertReleasedV4Header",
|
||||
"targetRestorer": "restoreReleasedV4Artifact"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-session-format": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-v2-to-v3": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** V4 framing retains released V3 event encoding and structural admission. */
|
||||
|
||||
import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatCodec, SessionFormatCurrentEncoder, SessionFormatHeader } from '@deepseek-ai/dsh-session-format'
|
||||
import { assertV3RowAdmission, releasedV3SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
import { assertReleasedV4Header } from './validation.ts'
|
||||
|
||||
/** V4 codec preserves the released V3 physical event representation. */
|
||||
export const releasedV4SessionFormatCodec = Object.freeze({
|
||||
version: 4,
|
||||
decodeHeader(value: unknown) {
|
||||
return { ...releasedV3SessionFormatCodec.decodeHeader(physicalV3(value)), version: 4 }
|
||||
},
|
||||
createDecoder(value, recovery) {
|
||||
const decoder = releasedV3SessionFormatCodec.createDecoder(physicalV3(value), recovery)
|
||||
return { ...decoder, header: { ...decoder.header, version: 4 } }
|
||||
},
|
||||
encodeHeader(header, inheritedEventCount) {
|
||||
assertReleasedV4Header(header)
|
||||
return { ...releasedV3SessionFormatCodec.encodeHeader({ ...header, version: 3 }, inheritedEventCount), version: 4 }
|
||||
},
|
||||
encodeEvent: releasedV3SessionFormatCodec.encodeEvent,
|
||||
} satisfies SessionFormatCodec & SessionFormatCurrentEncoder)
|
||||
|
||||
/**
|
||||
* Apply V4 structural admission before a scanner discards a recoverable suffix.
|
||||
* @param row - parsed physical row before framing and source-event range decoding.
|
||||
*/
|
||||
export function assertV4RowAdmission(row: unknown): void {
|
||||
assertV3RowAdmission(row)
|
||||
}
|
||||
|
||||
function physicalV3(value: unknown): SessionFormatHeader {
|
||||
if (!isSessionFormatJsonObject(value) || value['version'] !== 4) {
|
||||
throw new SessionFormatError('expected format v4 physical Session header')
|
||||
}
|
||||
return { ...value, version: 3 } as SessionFormatHeader
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Identity V3-to-V4 migration with frozen V3 framing and generation-aware delivery validation. */
|
||||
|
||||
export { releasedV3SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
export * from './codec.ts'
|
||||
export * from './migration.ts'
|
||||
export { assertReleasedV4Header, restoreReleasedV4Artifact } from './validation.ts'
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Streaming V3-to-V4 identity conversion with inherited cuts and delivery activation guards. */
|
||||
|
||||
import { SessionFormatError, SessionFormatUnsupportedMigrationError, defineSessionFormatMigration, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatJsonObject, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format'
|
||||
import { assertReleasedV3Header } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts'
|
||||
|
||||
/** Adjacent migration preserves admitted V3 event values and coordinates. */
|
||||
export const sessionFormatV3ToV4 = defineSessionFormatMigration({
|
||||
name: '@deepseek-ai/dsh-session-format-v3-to-v4',
|
||||
fromVersion: 3,
|
||||
toVersion: 4,
|
||||
migrateHeader(header) {
|
||||
assertReleasedV3Header(header)
|
||||
return { ...header, version: 4 }
|
||||
},
|
||||
createStage(input) { return new ReleasedV3ToV4Stage(input) },
|
||||
validateTargetHeader: assertReleasedV4Header,
|
||||
})
|
||||
|
||||
class ReleasedV3ToV4Stage implements SessionFormatMigrationStage {
|
||||
readonly headerInheritedEventCount?: number
|
||||
private cut: number | undefined
|
||||
private nextSeq = 0
|
||||
private foreignDeliverySeq: number | undefined
|
||||
|
||||
constructor(private readonly input: SessionFormatMigrationStageInput) {
|
||||
this.cut = input.sourceHeader.isSeeded ? undefined : 0
|
||||
if (!input.sourceHeader.isSeeded) this.headerInheritedEventCount = 0
|
||||
}
|
||||
|
||||
transformEvent(event: SessionFormatEvent, context: SessionFormatMigrationContext): void {
|
||||
if (event.seq !== this.nextSeq++) throw new SessionFormatError('format v3 source events must be dense')
|
||||
if (event.type === 'session/end-seed' && (event.data as SessionFormatJsonObject)['inherited'] === true) {
|
||||
if (!this.input.sourceHeader.isSeeded) throw new SessionFormatError('unseeded format v3 Session contains an inherited end-seed marker')
|
||||
this.cut = event.seq
|
||||
}
|
||||
const deliveryId = validateDeliveryAccepted(event, 3)
|
||||
if (event.type === 'session-log-deepseek/delivery-accepted') {
|
||||
if ((event.data as SessionFormatJsonObject)['sessionFormatVersion'] === 4) {
|
||||
throw new SessionFormatUnsupportedMigrationError('format v3 delivery marker claims target format v4')
|
||||
}
|
||||
if (deliveryId !== undefined && deliveryId !== this.input.sourceHeader.id) this.foreignDeliverySeq = event.seq
|
||||
}
|
||||
context.emitEvent(event)
|
||||
}
|
||||
|
||||
transformRun(run: SessionFormatEventRun, context: SessionFormatMigrationContext): void {
|
||||
for (const event of run.expand()) this.transformEvent(event, context)
|
||||
}
|
||||
|
||||
finish(_context: SessionFormatMigrationContext): number {
|
||||
const cut = sessionFormatCount(this.cut, 'format v3 inherited event count')
|
||||
if (this.input.sourceInheritedEventCount !== undefined && cut !== this.input.sourceInheritedEventCount) {
|
||||
throw new SessionFormatError('format v3 inherited cut disagrees with its source marker')
|
||||
}
|
||||
if (this.foreignDeliverySeq !== undefined
|
||||
&& (this.input.sourceHeader.parentSession === undefined || this.foreignDeliverySeq >= cut)) {
|
||||
throw new SessionFormatError('current-generation delivery marker names the wrong Session')
|
||||
}
|
||||
return cut
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** V4 metadata and relationship validation without changing recorded delivery generations. */
|
||||
|
||||
import { SessionFormatError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import { assertReleasedV3Header, restoreReleasedV3Artifact } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
|
||||
/**
|
||||
* Validate V4 metadata with the unchanged released V3 header fields.
|
||||
* @param header - decoded V4 Session header.
|
||||
*/
|
||||
export function assertReleasedV4Header(header: SessionFormatHeader): void {
|
||||
if (header.version !== 4) throw new SessionFormatError('expected format v4 header')
|
||||
assertReleasedV3Header({ ...header, version: 3 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate V4 relationships and delivery coordinates while retaining the original artifact.
|
||||
* @param artifact - complete detached V4 artifact.
|
||||
* @param knownEventTypes - event types understood by the installed Session package.
|
||||
* @returns the same validated artifact and event objects.
|
||||
*/
|
||||
export function restoreReleasedV4Artifact(artifact: SessionFormatArtifact, knownEventTypes: ReadonlySet<string>): SessionFormatArtifact {
|
||||
assertReleasedV4Header(artifact.header)
|
||||
const events = artifact.events.map((event): SessionFormatEvent => {
|
||||
validateDeliveryAccepted(event, 4)
|
||||
if (event.type !== 'session-log-deepseek/delivery-accepted') return event
|
||||
const data = event.data as SessionFormatJsonObject
|
||||
const version = data['sessionFormatVersion']
|
||||
// Only this private relationship view substitutes generations; recorded delivery identities stay unchanged.
|
||||
return version === 4 || version === 3
|
||||
? { ...event, data: { ...data, sessionFormatVersion: version === 4 ? 3 : 2 } }
|
||||
: event
|
||||
})
|
||||
restoreReleasedV3Artifact({ ...artifact, header: { ...artifact.header, version: 3 }, events }, knownEventTypes)
|
||||
return artifact
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate delivery generation and active-generation coordinates before evaluating ownership.
|
||||
* @param event - decoded event whose delivery payload may be inspected.
|
||||
* @param currentVersion - generation whose watermark coordinates are active.
|
||||
* @returns the active delivery's nonempty Session id, or undefined for other events and generations.
|
||||
*/
|
||||
export function validateDeliveryAccepted(event: SessionFormatEvent, currentVersion: 3 | 4): string | undefined {
|
||||
if (event.type !== 'session-log-deepseek/delivery-accepted') return undefined
|
||||
const data = event.data
|
||||
if (!isSessionFormatJsonObject(data)) throw new SessionFormatError('delivery-accepted data must be an object')
|
||||
const version = sessionFormatCount(data['sessionFormatVersion'] === undefined ? 0 : data['sessionFormatVersion'], 'delivery sessionFormatVersion')
|
||||
if (version !== currentVersion) return undefined
|
||||
const throughSeq = sessionFormatCount(data['throughSeq'], 'delivery throughSeq')
|
||||
if (throughSeq >= event.seq) throw new SessionFormatError('delivery throughSeq must precede its marker')
|
||||
const id = data['sessionId']
|
||||
if (typeof id !== 'string' || id.length === 0) throw new SessionFormatError('delivery requires a nonempty Session id')
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import { releasedV3SessionFormatCodec as previousCodec } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
import { assertReleasedV4Header, assertV4RowAdmission, releasedV3SessionFormatCodec, releasedV4SessionFormatCodec, restoreReleasedV4Artifact } from '../src/index.ts'
|
||||
|
||||
const header = { version: 4, id: 'parent', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
const fact: SessionFormatEvent = { type: 'feedback/record', seq: 0, time: 1, data: { text: 'retained' } }
|
||||
const types = new Set(['feedback/record', 'session-log-deepseek/delivery-accepted', 'session/end-seed'])
|
||||
const delivery = (version: number | undefined, sessionId = header.id): SessionFormatEvent => ({
|
||||
type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
|
||||
data: { sessionId, throughSeq: 0, ...(version === undefined ? {} : { sessionFormatVersion: version }) },
|
||||
})
|
||||
|
||||
function restore(events: readonly SessionFormatEvent[], sourceHeader = header) {
|
||||
return restoreReleasedV4Artifact({ header: sourceHeader, events, inheritedEventCount: 0 }, types)
|
||||
}
|
||||
|
||||
describe('V4 framing and restoration', () => {
|
||||
it('reuses the released V3 codec and round trips unchanged V3 event encoding', () => {
|
||||
expect(releasedV3SessionFormatCodec).toBe(previousCodec)
|
||||
const physical = releasedV4SessionFormatCodec.encodeHeader(header, 0)
|
||||
expect(physical).toEqual({ type: 'session', ...header })
|
||||
expect(releasedV4SessionFormatCodec.decodeHeader(physical)).toEqual(header)
|
||||
const decoder = releasedV4SessionFormatCodec.createDecoder(physical, 'strict')
|
||||
const output = new SessionFormatEventCollector()
|
||||
expect(decoder.header).toEqual(header)
|
||||
const row = releasedV4SessionFormatCodec.encodeEvent(fact)
|
||||
expect(row).toEqual(releasedV3SessionFormatCodec.encodeEvent(fact))
|
||||
decoder.decodeRow(row, output)
|
||||
expect(decoder.finish(output)).toBe(0)
|
||||
expect(output.values).toEqual([fact])
|
||||
const artifact = { header, events: output.values, inheritedEventCount: 0 }
|
||||
expect(restoreReleasedV4Artifact(artifact, types)).toBe(artifact)
|
||||
})
|
||||
|
||||
it('rejects mismatched versions and malformed logical and physical metadata', () => {
|
||||
expect(() => { assertReleasedV4Header({ ...header, version: 3 }) }).toThrow('v4 header')
|
||||
expect(() => { assertReleasedV4Header({ ...header, createdAt: -1 }) }).toThrow('createdAt')
|
||||
expect(() => releasedV4SessionFormatCodec.decodeHeader(null)).toThrow('v4 physical')
|
||||
expect(() => releasedV4SessionFormatCodec.createDecoder({ ...header, version: 3 }, 'strict')).toThrow('v4 physical')
|
||||
expect(() => releasedV4SessionFormatCodec.decodeHeader({ type: 'session', ...header, extra: 1 })).toThrow(/field|member/)
|
||||
})
|
||||
|
||||
it('retains predecessor and future delivery generations without activating their watermarks', () => {
|
||||
for (const version of [undefined, 0, 1, 2, 3, 5]) {
|
||||
const marker = { ...delivery(version, 'foreign'), data: { ...delivery(version).data as SessionFormatJsonObject, sessionId: 'foreign', throughSeq: 500 } }
|
||||
expect(restore([fact, marker]).events[1]).toBe(marker)
|
||||
}
|
||||
const current = delivery(4)
|
||||
expect(restore([fact, current]).events[1]).toBe(current)
|
||||
expect(() => restore([fact, delivery(4, 'foreign')])).toThrow('wrong Session')
|
||||
})
|
||||
|
||||
it('rejects malformed active delivery coordinates and malformed generation identifiers', () => {
|
||||
const marker = delivery(4)
|
||||
const data = marker.data as SessionFormatJsonObject
|
||||
for (const throughSeq of [-1, 1, 1.5]) {
|
||||
expect(() => restore([fact, { ...marker, data: { ...data, throughSeq } }])).toThrow('throughSeq')
|
||||
}
|
||||
for (const sessionId of ['', null, 3]) {
|
||||
expect(() => restore([fact, { ...marker, data: { ...data, sessionId } }])).toThrow('nonempty')
|
||||
}
|
||||
for (const sessionFormatVersion of [-1, -0, 1.5, '4', null]) {
|
||||
expect(() => restore([fact, { ...marker, data: { ...data, sessionFormatVersion } }])).toThrow('sessionFormatVersion')
|
||||
}
|
||||
expect(() => restore([fact, { ...marker, data: null }])).toThrow('data must be an object')
|
||||
const reader = sessionFormatCatalog.createRestore({ type: 'session', ...header }, { recovery: 'strict', validation: 'current' })
|
||||
reader.decodeRow(fact)
|
||||
reader.decodeRow({ ...marker, data: { ...data, throughSeq: 1 } })
|
||||
expect(() => reader.finish()).toThrow('throughSeq')
|
||||
})
|
||||
|
||||
it('preserves inherited and local events and admits foreign active deliveries inside the seed only', () => {
|
||||
const inherited = delivery(4, 'ancestor')
|
||||
const artifact = {
|
||||
header: { ...header, isSeeded: true, parentSession: 'ancestor' }, inheritedEventCount: 2,
|
||||
events: [fact, inherited, { type: 'session/end-seed', seq: 2, time: 3, data: { inherited: true } }, { ...fact, seq: 3 }],
|
||||
}
|
||||
expect(restoreReleasedV4Artifact(artifact, types)).toBe(artifact)
|
||||
expect(() => restoreReleasedV4Artifact({ ...artifact, inheritedEventCount: 1 }, types)).toThrow('marker')
|
||||
})
|
||||
|
||||
it('preserves unknown ignorable values and requires installed vocabulary for unknown required events', () => {
|
||||
const opaque = { type: 'external/opaque', seq: 0, time: 1, data: { untouched: ['a', 3] }, ignorable: true }
|
||||
expect(restore([opaque]).events[0]).toBe(opaque)
|
||||
const required = { type: 'external/required', seq: 0, time: 1, data: null }
|
||||
expect(() => restore([required])).toThrow('unknown event type')
|
||||
const artifact = { header, events: [required], inheritedEventCount: 0 }
|
||||
expect(restoreReleasedV4Artifact(artifact, new Set([required.type]))).toBe(artifact)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ type: 'request/header', seq: 0, time: 1, data: { header: { system: '' } } },
|
||||
{ type: 'system/message', seq: 0, time: 1, data: {} },
|
||||
{ type: 'tool/code-dispatch', seq: 0, time: 1, data: null },
|
||||
])('refuses structural $type violations even after recoverable corruption', (row) => {
|
||||
expect(() => { assertV4RowAdmission(row) }).toThrow()
|
||||
const decoder = releasedV4SessionFormatCodec.createDecoder({ type: 'session', ...header }, 'recoverable')
|
||||
const output = new SessionFormatEventCollector()
|
||||
decoder.decodeRow(null, output)
|
||||
expect(() => { decoder.decodeRow(row, output) }).toThrow()
|
||||
})
|
||||
|
||||
it('leaves ordinary framing recovery to the released decoder', () => {
|
||||
expect(() => { assertV4RowAdmission(null) }).not.toThrow()
|
||||
expect(() => { assertV4RowAdmission(fact) }).not.toThrow()
|
||||
const decoder = releasedV4SessionFormatCodec.createDecoder({ type: 'session', ...header }, 'recoverable')
|
||||
const output = new SessionFormatEventCollector()
|
||||
decoder.decodeRow(fact, output)
|
||||
decoder.decodeRow(null, output)
|
||||
expect(decoder.finish(output)).toBe(0)
|
||||
expect(output.values).toEqual([fact])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import { releasedV3SessionFormatCodec, sessionFormatV3ToV4 } from '../src/index.ts'
|
||||
|
||||
const header: SessionFormatHeader = { version: 3, id: 'identity', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
const fact: SessionFormatEvent = { type: 'feedback/record', seq: 0, time: 2, data: { text: 'retained' } }
|
||||
const seed: SessionFormatEvent = { type: 'session/end-seed', seq: 1, time: 3, data: { inherited: true } }
|
||||
const delivery = (version: number | undefined, sessionId = header.id): SessionFormatEvent => ({
|
||||
type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 3,
|
||||
data: { sessionId, throughSeq: 0, ...(version === undefined ? {} : { sessionFormatVersion: version }) },
|
||||
})
|
||||
|
||||
function stage(sourceHeader = header, sourceInheritedEventCount?: number) {
|
||||
return sessionFormatV3ToV4.createStage({
|
||||
sourceHeader, targetHeader: sessionFormatV3ToV4.migrateHeader(sourceHeader),
|
||||
sourceInheritedEventCount, sourceKind: 'decoded',
|
||||
})
|
||||
}
|
||||
|
||||
function migrate(events: readonly SessionFormatEvent[], sourceHeader = header, cut?: number) {
|
||||
const current = stage(sourceHeader, cut)
|
||||
const output = new SessionFormatEventCollector()
|
||||
for (const event of events) current.transformEvent(event, output)
|
||||
return { events: output.values, cut: current.finish(output) }
|
||||
}
|
||||
|
||||
function restore(events: readonly SessionFormatEvent[], sourceHeader = header) {
|
||||
const reader = sessionFormatCatalog.createRestore({ type: 'session', ...sourceHeader }, { recovery: 'strict', validation: 'current' })
|
||||
for (const event of events) reader.decodeRow(event)
|
||||
return reader.finish()
|
||||
}
|
||||
|
||||
describe('V3 to V4 identity migration', () => {
|
||||
it('changes only the header version and retains event objects, payloads, timestamps, and coordinates', () => {
|
||||
const rows = [fact, delivery(3)]
|
||||
const before = JSON.stringify({ header, rows })
|
||||
expect(sessionFormatV3ToV4.migrateHeader(header)).toEqual({ ...header, version: 4 })
|
||||
expect(migrate(rows)).toEqual({ events: rows, cut: 0 })
|
||||
expect(migrate(rows).events[0]).toBe(fact)
|
||||
expect(migrate(rows).events[1]).toBe(rows[1])
|
||||
expect(JSON.stringify({ header, rows })).toBe(before)
|
||||
expect(stage().headerInheritedEventCount).toBe(0)
|
||||
expect(migrate([])).toEqual({ events: [], cut: 0 })
|
||||
expect(restore(rows)).toEqual({ header: { ...header, version: 4 }, inheritedEventCount: 0, events: rows })
|
||||
expect(restore(rows)).toEqual(restore(rows))
|
||||
})
|
||||
|
||||
it('keeps interleaved stage state independent and derives an inherited cut unavailable before EOF', () => {
|
||||
const inherited = stage({ ...header, isSeeded: true, parentSession: 'ancestor' })
|
||||
const local = stage()
|
||||
const a = new SessionFormatEventCollector()
|
||||
const b = new SessionFormatEventCollector()
|
||||
expect(inherited.headerInheritedEventCount).toBeUndefined()
|
||||
inherited.transformEvent(fact, a)
|
||||
local.transformEvent(fact, b)
|
||||
inherited.transformEvent(seed, a)
|
||||
expect(local.finish(b)).toBe(0)
|
||||
expect(inherited.finish(a)).toBe(1)
|
||||
expect(a.values).toEqual([fact, seed])
|
||||
expect(b.values).toEqual([fact])
|
||||
expect(migrate([fact, seed], { ...header, isSeeded: true }, 1).cut).toBe(1)
|
||||
})
|
||||
|
||||
it('consumes compact runs without changing their event values', () => {
|
||||
const current = stage()
|
||||
const output = new SessionFormatEventCollector()
|
||||
current.transformRun({ runType: 'identity', eventCount: 1, firstSeq: 0, *expand() { yield fact } }, output)
|
||||
expect(current.finish(output)).toBe(0)
|
||||
expect(output.values[0]).toBe(fact)
|
||||
})
|
||||
|
||||
it('rejects sparse sequences and inconsistent inheritance', () => {
|
||||
expect(() => migrate([{ ...fact, seq: 1 }])).toThrow('dense')
|
||||
expect(() => migrate([fact, seed])).toThrow('unseeded')
|
||||
expect(() => migrate([fact], { ...header, isSeeded: true })).toThrow('inherited event count')
|
||||
expect(() => migrate([fact, seed], { ...header, isSeeded: true }, 0)).toThrow('disagrees')
|
||||
expect(() => migrate([fact, { ...seed, data: {} }], { ...header, isSeeded: true })).toThrow('inherited event count')
|
||||
})
|
||||
|
||||
it('refuses a target-generation delivery without changing predecessor or future delivery values', () => {
|
||||
expect(() => migrate([fact, delivery(4)])).toThrow('claims target format v4')
|
||||
expect(() => restore([fact, delivery(4)])).toThrow('claims target format v4')
|
||||
for (const version of [undefined, 0, 1, 2, 3, 5]) {
|
||||
const marker = delivery(version)
|
||||
expect(migrate([fact, marker]).events[1]).toBe(marker)
|
||||
}
|
||||
})
|
||||
|
||||
it('checks active V3 coordinates before the delivery becomes historical', () => {
|
||||
const marker = delivery(3)
|
||||
const data = marker.data as SessionFormatJsonObject
|
||||
for (const throughSeq of [-1, 1, 1.5]) {
|
||||
expect(() => migrate([fact, { ...marker, data: { ...data, throughSeq } }])).toThrow('throughSeq')
|
||||
}
|
||||
expect(() => migrate([fact, { ...marker, data: { ...data, sessionId: '' } }])).toThrow('nonempty')
|
||||
expect(() => restore([fact, { ...marker, data: { ...data, throughSeq: 1 } }])).toThrow('throughSeq')
|
||||
const historical = { ...marker, data: { sessionId: 'ancestor', throughSeq: 500, sessionFormatVersion: 2 } }
|
||||
expect(restore([fact, historical]).events[1]).toEqual(historical)
|
||||
})
|
||||
|
||||
it('permits foreign active deliveries only inside a parent seed', () => {
|
||||
const foreign = delivery(3, 'ancestor')
|
||||
expect(() => migrate([fact, foreign])).toThrow('wrong Session')
|
||||
expect(() => migrate([fact, foreign], { ...header, parentSession: 'ancestor' })).toThrow('wrong Session')
|
||||
const rows = [fact, foreign, { ...seed, seq: 2 }]
|
||||
expect(migrate(rows, { ...header, isSeeded: true, parentSession: 'ancestor' }).cut).toBe(2)
|
||||
expect(() => migrate(rows, { ...header, isSeeded: true })).toThrow('wrong Session')
|
||||
expect(() => migrate([fact, seed, { ...foreign, seq: 2 }], { ...header, isSeeded: true, parentSession: 'ancestor' })).toThrow('wrong Session')
|
||||
})
|
||||
|
||||
it('retains unknown ignorable values and refuses unknown required events during strict catalog restoration', () => {
|
||||
const opaque: SessionFormatEvent = {
|
||||
type: 'external/opaque', seq: 0, time: -5, ignorable: true,
|
||||
data: { nested: { seq: 40 }, content: ['opaque'] }, surfaceOp: { future: { ref: 9 } },
|
||||
}
|
||||
expect(restore([opaque]).events).toEqual([opaque])
|
||||
const { ignorable: _ignorable, ...required } = opaque
|
||||
expect(() => restore([required])).toThrow('unknown event type')
|
||||
expect(() => restore([{ ...fact, time: 1.5 }])).toThrow('time')
|
||||
expect(() => restore([{ ...fact, extra: 1 }])).toThrow(/field|member/)
|
||||
})
|
||||
|
||||
it.each([0, 1, 2, 3])('restores a seeded V%i chain, including upstream cardinality changes, and reopens V4', (version) => {
|
||||
const rows = [
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'step/start', data: { turn: 1, step: 1 } },
|
||||
{ type: 'user/message', data: { id: 'user', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'input' }] }, surfaceOp: 'append' },
|
||||
{ type: 'request/header', data: { header: { config: { provider: 'mock', model: 'mock' }, system: 'seed prompt' }, reason: 'initial' } },
|
||||
{ type: 'step/end', data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/end-seed', data: version < 2 ? {} : { inherited: true } },
|
||||
].map((event, seq): SessionFormatEvent => ({ ...event, seq, time: seq + 1 }))
|
||||
const source = version === 3 ? restore(rows, { ...header, version: 2, isSeeded: true }).events : rows
|
||||
const physical = version < 2
|
||||
? { type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0, parentSession: 'ancestor', seedLength: 6 }
|
||||
: { type: 'session', ...header, version, isSeeded: true, parentSession: 'ancestor' }
|
||||
const before = JSON.stringify({ physical, source })
|
||||
const reader = sessionFormatCatalog.createRestore(physical, { recovery: 'strict', validation: 'current' })
|
||||
for (const row of source) reader.decodeRow(version === 3 ? releasedV3SessionFormatCodec.encodeEvent(row) : row)
|
||||
const artifact = reader.finish()
|
||||
expect(artifact.header.version).toBe(4)
|
||||
expect(artifact.inheritedEventCount).toBe(8)
|
||||
expect(artifact.events.filter(event => event.type === 'system/message')).toHaveLength(2)
|
||||
expect(artifact.events.at(-1)?.seq).toBe(8)
|
||||
const reopened = sessionFormatCatalog.createRestore(sessionFormatCatalog.encodeCurrentHeader(artifact.header, 8), {
|
||||
recovery: 'strict', validation: 'current',
|
||||
})
|
||||
for (const event of artifact.events) reopened.decodeRow(sessionFormatCatalog.encodeCurrentEvent(event))
|
||||
expect(reopened.finish()).toEqual(artifact)
|
||||
expect(JSON.stringify({ physical, source })).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../session-format" },
|
||||
{ "path": "../session-format-v2-to-v3" }
|
||||
]
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-catalog": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-v2-to-v3": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-v3-to-v4": "workspace:^",
|
||||
"@deepseek-ai/node-addon-system": "workspace:^",
|
||||
"koffi": "^3.1.0",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
|
||||
@@ -23,7 +23,7 @@ import { parseSessionFormatLogFilename, sessionFormatLogFilename, SessionFormatU
|
||||
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatRecovery, SessionFormatRestore } from '@deepseek-ai/dsh-session-format'
|
||||
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import { assertV3RowAdmission } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
import { assertV4RowAdmission } from '@deepseek-ai/dsh-session-format-v3-to-v4'
|
||||
import {
|
||||
SessionFormatUnsupportedError,
|
||||
sessionFormatVersionRefusal,
|
||||
@@ -488,7 +488,7 @@ export class SessionLogScanner {
|
||||
// This scanner accepts only current-generation files. Owned structural refusal must
|
||||
// precede its recoverable-tail suppression, independently of the strict decoder state.
|
||||
try {
|
||||
assertV3RowAdmission(decoded)
|
||||
assertV4RowAdmission(decoded)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionFormatUnsupportedMigrationError) throw new SessionFormatUnsupportedError(error.message)
|
||||
throw error
|
||||
@@ -502,7 +502,7 @@ export class SessionLogScanner {
|
||||
try {
|
||||
this.restore.decodeRow(decoded)
|
||||
} catch (error: unknown) {
|
||||
// Unsupported V3 rows have already been refused before recovery.
|
||||
// Unsupported current-format rows have already been refused before recovery.
|
||||
/* v8 ignore next -- every production Session format decoder rejects with Error. */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
const issue = new Error(`corrupt session log: invalid committed event at line ${this.eventLine}: ${detail}`, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const packageRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
@@ -16,6 +17,7 @@ describe.skipIf(!built)('built migration verifier (plain node)', () => {
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-built-migration-'))
|
||||
@@ -31,7 +33,7 @@ describe.skipIf(!built)('built migration verifier (plain node)', () => {
|
||||
const handle = await ctx.sessionPersistence.open(id, 'write')
|
||||
await handle.close()
|
||||
await ctx.sessionPersistence.flush()
|
||||
const currentPath = join(directory, 'session.v3.jsonl')
|
||||
const currentPath = join(directory, 'session.v' + SESSION_FORMAT_VERSION + '.jsonl')
|
||||
const header = JSON.parse((await readFile(currentPath, 'utf8')).trim())
|
||||
await mkdir(join(root, 'lib'))
|
||||
await copyFile('package.json', join(root, 'package.json'))
|
||||
@@ -71,7 +73,7 @@ describe.skipIf(!built)('built migration verifier (plain node)', () => {
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
expect(JSON.parse(stdout.trim())).toEqual({
|
||||
id: 'built-migration-worker', version: 3, verified: true, refused: false,
|
||||
id: 'built-migration-worker', version: SESSION_FORMAT_VERSION, verified: true, refused: false,
|
||||
refusal: 'current session generation contains 0 events, expected 1',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** V2 content admission refuses entire generations without publishing a valid prefix. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, 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'
|
||||
@@ -198,7 +198,7 @@ describe.each(['none', 'zstd'] as const)('V2 admitted content publication (%s)',
|
||||
const reader = await ctx.sessionPersistence.open(id, 'read')
|
||||
const restored = await (async () => {
|
||||
try {
|
||||
expect(reader.header.version).toBe(3)
|
||||
expect(reader.header.version).toBe(SESSION_FORMAT_VERSION)
|
||||
return await reader.read()
|
||||
} finally { await reader.close() }
|
||||
})()
|
||||
@@ -219,7 +219,7 @@ describe.each(['none', 'zstd'] as const)('V2 admitted content publication (%s)',
|
||||
expect((await writer.read()).events).toEqual(restored.events)
|
||||
await writer.flush()
|
||||
} finally { await writer.close() }
|
||||
const successor = generationLogPath(root!, undefined, id, 3, compression)
|
||||
const successor = generationLogPath(root!, undefined, id, SESSION_FORMAT_VERSION, compression)
|
||||
const published = await readFile(successor)
|
||||
let decoded = published
|
||||
if (compression === 'zstd') {
|
||||
@@ -237,7 +237,7 @@ describe.each(['none', 'zstd'] as const)('V2 admitted content publication (%s)',
|
||||
const fresh = await mount(compression)
|
||||
const reopened = await fresh.sessionPersistence.open(id, 'read')
|
||||
try {
|
||||
expect(reopened.header.version).toBe(3)
|
||||
expect(reopened.header.version).toBe(SESSION_FORMAT_VERSION)
|
||||
expect((await reopened.read()).events).toEqual(restored.events)
|
||||
} finally { await reopened.close() }
|
||||
expect(await observe(path)).toEqual(original)
|
||||
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
@@ -8,8 +8,8 @@ import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { generationLogPath, scanLog } from '../src/format.ts'
|
||||
|
||||
const id = SessionId('v3-admission')
|
||||
const header = { type: 'session', version: 3, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
|
||||
const id = SessionId('current-admission')
|
||||
const header = { type: 'session', version: SESSION_FORMAT_VERSION, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
|
||||
const start = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }
|
||||
const prefix = [header, start].map(row => JSON.stringify(row)).join('\n') + '\n'
|
||||
const obsoleteTypes = ['tool/code-dispatch-start', 'tool/code-dispatch'] as const
|
||||
@@ -22,12 +22,12 @@ function obsoleteEvent(type: string, ignorable?: true) {
|
||||
}
|
||||
}
|
||||
|
||||
describe('native V3 event admission at EOF', () => {
|
||||
describe('current event admission at EOF', () => {
|
||||
let root: string
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-v3-admission-'))
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-current-admission-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
|
||||
})
|
||||
@@ -41,7 +41,7 @@ describe('native V3 event admission at EOF', () => {
|
||||
})
|
||||
|
||||
async function store(bytes: Buffer): Promise<string> {
|
||||
const path = generationLogPath(root, undefined, id, 3, 'none')
|
||||
const path = generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, 'none')
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, bytes)
|
||||
return path
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
@@ -145,7 +146,7 @@ interface TestGenerationFormatAdapter extends JsonlGenerationFormatAdapter {
|
||||
}
|
||||
|
||||
function adapter(overrides: Partial<TestGenerationFormatAdapter> = {}): TestGenerationFormatAdapter {
|
||||
const currentVersion = overrides.currentVersion ?? 3
|
||||
const currentVersion = overrides.currentVersion ?? SESSION_FORMAT_VERSION
|
||||
return {
|
||||
currentVersion,
|
||||
createRestore(headerValue) {
|
||||
@@ -287,7 +288,7 @@ async function decodeZstdJsonl(path: string): Promise<string> {
|
||||
}
|
||||
|
||||
describe('JSONL immutable generation publication', () => {
|
||||
it('refuses V2 messages without surface markers before writing a V3 successor', async () => {
|
||||
it('refuses V2 messages without surface markers before writing a current successor', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root, 'none', catalogAdapter(), 2)
|
||||
const events = assistantLifecycle('assistant/message', assistantData())
|
||||
@@ -332,7 +333,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(await readdir(root)).toEqual(['session.v2.jsonl'])
|
||||
})
|
||||
|
||||
it('publishes canonical V3 replacements and headers while retaining exact V2 bytes', async () => {
|
||||
it('publishes canonical replacements and current headers while retaining exact V2 bytes', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root, 'none', catalogAdapter(), 2)
|
||||
const config = { provider: 'mock', model: 'mock' }
|
||||
@@ -370,8 +371,8 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(await readFile(request.sourcePath)).toEqual(source)
|
||||
const written = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
|
||||
.map(row => JSON.parse(row) as unknown)
|
||||
expect(written).toEqual([header(3), ...canonical])
|
||||
expect((await readdir(root)).sort()).toEqual(['session.v2.jsonl', 'session.v3.jsonl'])
|
||||
expect(written).toEqual([header(SESSION_FORMAT_VERSION), ...canonical])
|
||||
expect((await readdir(root)).sort()).toEqual(['session.v2.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
})
|
||||
|
||||
it('returns migrated events while publication is still waiting for verification', async () => {
|
||||
@@ -407,7 +408,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
release.resolve(undefined)
|
||||
await publication
|
||||
const [writtenHeader, ...writtenEvents] = (await readFile(request.currentPath, 'utf8')).trimEnd().split('\n')
|
||||
expect(JSON.parse(writtenHeader as string)).toEqual({ ...header(3), isSeeded: false })
|
||||
expect(JSON.parse(writtenHeader as string)).toEqual({ ...header(SESSION_FORMAT_VERSION), isSeeded: false })
|
||||
expect(writtenEvents.map(row => JSON.parse(row) as unknown)).toEqual([boundaryEvent, largeEvent, finalEvent])
|
||||
})
|
||||
|
||||
@@ -487,14 +488,14 @@ describe('JSONL immutable generation publication', () => {
|
||||
|
||||
it('verifies exact current identity, completeness, and event count', async () => {
|
||||
const root = await tempRoot()
|
||||
const path = generationPath(root, 3, 'none')
|
||||
await writeFile(path, line({ ...header(3), isSeeded: false }) + line(event0))
|
||||
const path = generationPath(root, SESSION_FORMAT_VERSION, 'none')
|
||||
await writeFile(path, line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line(event0))
|
||||
|
||||
await expect(verifyJsonlCurrentGeneration(path, 'none', 'other', 1))
|
||||
.rejects.toThrow('expected "other"')
|
||||
await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 2))
|
||||
.rejects.toThrow('contains 1 events')
|
||||
await writeFile(path, line({ ...header(3), isSeeded: false }) + JSON.stringify(event0))
|
||||
await writeFile(path, line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + JSON.stringify(event0))
|
||||
await expect(verifyJsonlCurrentGeneration(path, 'none', 'generation-test', 1))
|
||||
.rejects.toThrow('torn physical tail')
|
||||
|
||||
@@ -503,7 +504,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
.rejects.toThrow('empty or header-less')
|
||||
await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 0))
|
||||
.rejects.toThrow('empty or header-less Zstandard')
|
||||
const headerFrame = await compressZstdFrame(line({ ...header(3), isSeeded: false }))
|
||||
const headerFrame = await compressZstdFrame(line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }))
|
||||
await writeFile(path, Buffer.concat([headerFrame, await compressZstdFrame(JSON.stringify(event0))]))
|
||||
await expect(verifyJsonlCurrentGeneration(path, 'zstd', 'generation-test', 1))
|
||||
.rejects.toThrow('torn physical tail')
|
||||
@@ -517,9 +518,9 @@ describe('JSONL immutable generation publication', () => {
|
||||
|
||||
it('keeps complete Assistant stream checks in current-generation verification', async () => {
|
||||
const root = await tempRoot()
|
||||
const path = generationPath(root, 3, 'none')
|
||||
const path = generationPath(root, SESSION_FORMAT_VERSION, 'none')
|
||||
const verify = async (events: readonly SessionFormatEvent[]) => {
|
||||
await writeFile(path, line(header(3)) + events.map(line).join(''))
|
||||
await writeFile(path, line(header(SESSION_FORMAT_VERSION)) + events.map(line).join(''))
|
||||
return verifyJsonlCurrentGeneration(path, 'none', 'generation-test', events.length)
|
||||
}
|
||||
|
||||
@@ -573,7 +574,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
const differentRoot = await tempRoot()
|
||||
const different = options(differentRoot, 'none', streamingAdapter())
|
||||
await writeFile(different.sourcePath, line(header(0)) + line(event0))
|
||||
await writeFile(different.currentPath, line({ ...header(3), isSeeded: false }) + line({ ...event0, time: 99 }))
|
||||
await writeFile(different.currentPath, line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line({ ...event0, time: 99 }))
|
||||
const conflicted = await prepareJsonlMigration({
|
||||
...different,
|
||||
verifyCurrentFile: verifier(),
|
||||
@@ -585,7 +586,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
await writeFile(unchecked.sourcePath, line(header(0)) + line(event0))
|
||||
await writeFile(
|
||||
unchecked.currentPath,
|
||||
line({ ...header(3), isSeeded: false }) + line({ ...event0, time: 99 }),
|
||||
line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line({ ...event0, time: 99 }),
|
||||
)
|
||||
const uncheckedPublication = await prepareJsonlMigration({
|
||||
...unchecked,
|
||||
@@ -641,8 +642,8 @@ describe('JSONL immutable generation publication', () => {
|
||||
|
||||
it('checks migration and verification identities exactly', async () => {
|
||||
const verifyRoot = await tempRoot()
|
||||
const currentPath = generationPath(verifyRoot, 3, 'none')
|
||||
await writeFile(currentPath, line(header(3)))
|
||||
const currentPath = generationPath(verifyRoot, SESSION_FORMAT_VERSION, 'none')
|
||||
await writeFile(currentPath, line(header(SESSION_FORMAT_VERSION)))
|
||||
let statCount = 0
|
||||
await expect(createJsonlGenerationTestRuntime({
|
||||
fs: { stat: async path => ({ ...await stat(path, { bigint: true }), ctimeNs: BigInt(++statCount) }) },
|
||||
@@ -665,7 +666,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
})
|
||||
await expect(mismatched.publish()).rejects.toThrow('changed during verification')
|
||||
|
||||
const current = options(await tempRoot(), 'none', streamingAdapter(), 3)
|
||||
const current = options(await tempRoot(), 'none', streamingAdapter(), SESSION_FORMAT_VERSION)
|
||||
await expect(prepareJsonlMigration({ ...current, verifyCurrentFile: vi.fn() }))
|
||||
.rejects.toThrow('requires a historical source')
|
||||
|
||||
@@ -735,10 +736,10 @@ describe('JSONL immutable generation publication', () => {
|
||||
verifyCurrentFile: verifier(),
|
||||
})
|
||||
await winPrepared.publish()
|
||||
expect(await readFile(win.currentPath, 'utf8')).toContain('"version":3')
|
||||
expect(await readFile(win.currentPath, 'utf8')).toContain(`"version":${SESSION_FORMAT_VERSION}`)
|
||||
})
|
||||
|
||||
it('publishes v3 beside an immutable suffixless v0 source', async () => {
|
||||
it('publishes the current generation beside an immutable suffixless v0 source', async () => {
|
||||
const root = await tempRoot()
|
||||
const request = { ...options(root), signal: new AbortController().signal }
|
||||
const source = Buffer.from(line(header(0)) + line(event0))
|
||||
@@ -749,13 +750,13 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(result).toMatchObject({
|
||||
status: 'migrated',
|
||||
fromVersion: 0,
|
||||
toVersion: 3,
|
||||
toVersion: SESSION_FORMAT_VERSION,
|
||||
path: request.currentPath,
|
||||
sourcePath: request.sourcePath,
|
||||
})
|
||||
expect(await readFile(request.sourcePath)).toEqual(source)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
})
|
||||
|
||||
it.each(['none', 'zstd'] as const)(
|
||||
@@ -884,15 +885,15 @@ describe('JSONL immutable generation publication', () => {
|
||||
createRestore: (value) => {
|
||||
const restore = wrongBase.createRestore(value)
|
||||
return {
|
||||
header: { ...restore.header, version: 4 },
|
||||
header: { ...restore.header, version: SESSION_FORMAT_VERSION + 1 },
|
||||
decodeRow: (row) => { restore.decodeRow(row) },
|
||||
finish: () => {
|
||||
const artifact = restore.finish()
|
||||
return { ...artifact, header: { ...artifact.header, version: 4 } }
|
||||
return { ...artifact, header: { ...artifact.header, version: SESSION_FORMAT_VERSION + 1 } }
|
||||
},
|
||||
}
|
||||
},
|
||||
})))).rejects.toThrow('format migration returned v4, expected v3')
|
||||
})))).rejects.toThrow(`format migration returned v${SESSION_FORMAT_VERSION + 1}, expected v${SESSION_FORMAT_VERSION}`)
|
||||
expect(await readdir(blockedRoot)).toEqual(['session.jsonl'])
|
||||
expect(await readdir(ordinaryRoot)).toEqual(['session.jsonl'])
|
||||
expect(await readdir(wrongRoot)).toEqual(['session.jsonl'])
|
||||
@@ -933,8 +934,8 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(await readFile(request.sourcePath)).toEqual(source)
|
||||
const sourceAfter = await stat(request.sourcePath, { bigint: true })
|
||||
expect([sourceAfter.dev, sourceAfter.ino]).toEqual([sourceBefore.dev, sourceBefore.ino])
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line({ ...header(3), isSeeded: false }) + line(event0))
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line({ ...header(SESSION_FORMAT_VERSION), isSeeded: false }) + line(event0))
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
})
|
||||
|
||||
it.each(['none', 'zstd'] as const)(
|
||||
@@ -958,7 +959,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
const currentText = compression === 'zstd'
|
||||
? await decodeZstdJsonl(request.currentPath)
|
||||
: await readFile(request.currentPath, 'utf8')
|
||||
expect(currentText).toBe(line(header(3)) + line(event0))
|
||||
expect(currentText).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -984,9 +985,9 @@ describe('JSONL immutable generation publication', () => {
|
||||
await ensureJsonlGenerationCurrent(emptyTailRequest)
|
||||
await ensureJsonlGenerationCurrent(tornRequest)
|
||||
|
||||
expect(await decodeZstdJsonl(headerRequest.currentPath)).toBe(line(header(3)))
|
||||
expect(await decodeZstdJsonl(emptyTailRequest.currentPath)).toBe(line(header(3)))
|
||||
expect(await decodeZstdJsonl(tornRequest.currentPath)).toBe(line(header(3)) + line(event0) + line(event1))
|
||||
expect(await decodeZstdJsonl(headerRequest.currentPath)).toBe(line(header(SESSION_FORMAT_VERSION)))
|
||||
expect(await decodeZstdJsonl(emptyTailRequest.currentPath)).toBe(line(header(SESSION_FORMAT_VERSION)))
|
||||
expect(await decodeZstdJsonl(tornRequest.currentPath)).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0) + line(event1))
|
||||
})
|
||||
|
||||
it('rejects header-less raw and Zstandard sources and a non-independent Zstandard header frame', async () => {
|
||||
@@ -1044,7 +1045,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
await expect(ensureJsonlGenerationCurrent(refused)).rejects.toThrow('row 2 is not valid JSON')
|
||||
|
||||
expect(await readFile(dropped.sourcePath, 'utf8')).toBe(incomplete)
|
||||
expect(await readFile(dropped.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(dropped.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
expect(await readFile(refused.sourcePath, 'utf8')).toBe(committed)
|
||||
expect(await readdir(refusedRoot)).toEqual(['session.jsonl'])
|
||||
})
|
||||
@@ -1058,7 +1059,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
await ensureJsonlGenerationCurrent(request)
|
||||
|
||||
expect(await readFile(request.sourcePath)).toEqual(source)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('validates canonical lowercase generation filenames and one shared directory', async () => {
|
||||
@@ -1072,11 +1073,11 @@ describe('JSONL immutable generation publication', () => {
|
||||
message: 'source path must end with "session.jsonl"',
|
||||
},
|
||||
{
|
||||
request: { ...options(root), currentPath: join(root, 'session.V3.jsonl') },
|
||||
message: 'current JSONL generation path must end with "session.v3.jsonl"',
|
||||
request: { ...options(root), currentPath: join(root, `session.V${SESSION_FORMAT_VERSION}.jsonl`) },
|
||||
message: `current JSONL generation path must end with "session.v${SESSION_FORMAT_VERSION}.jsonl"`,
|
||||
},
|
||||
{
|
||||
request: { ...options(root), currentPath: generationPath(other, 3, 'none') },
|
||||
request: { ...options(root), currentPath: generationPath(other, SESSION_FORMAT_VERSION, 'none') },
|
||||
message: 'must share one Session directory',
|
||||
},
|
||||
]
|
||||
@@ -1160,7 +1161,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect((await readdir(root)).sort()).toEqual([
|
||||
'session.jsonl',
|
||||
'session.migration.collision.jsonl.tmp',
|
||||
'session.v3.jsonl',
|
||||
`session.v${SESSION_FORMAT_VERSION}.jsonl`,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1168,7 +1169,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
const root = await tempRoot()
|
||||
const request = options(root)
|
||||
const source = Buffer.from(line(header(0)) + line(event0))
|
||||
const current = Buffer.from(line(header(3)) + line(event0))
|
||||
const current = Buffer.from(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
await writeFile(request.sourcePath, source)
|
||||
await writeFile(request.currentPath, current)
|
||||
|
||||
@@ -1177,7 +1178,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(result).toMatchObject({ status: 'migrated', path: request.currentPath })
|
||||
expect(await readFile(request.sourcePath)).toEqual(source)
|
||||
expect(await readFile(request.currentPath)).toEqual(current)
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
})
|
||||
|
||||
it.each(['none', 'zstd'] as const)(
|
||||
@@ -1189,8 +1190,8 @@ describe('JSONL immutable generation publication', () => {
|
||||
? await encodeZstd(0, [event0])
|
||||
: Buffer.from(line(header(0)) + line(event0))
|
||||
const expected = compression === 'zstd'
|
||||
? await encodeZstd(3, [event0])
|
||||
: Buffer.from(line(header(3)) + line(event0))
|
||||
? await encodeZstd(SESSION_FORMAT_VERSION, [event0])
|
||||
: Buffer.from(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
const appended = compression === 'zstd'
|
||||
? await compressZstdFrame(line(event1))
|
||||
: Buffer.from(line(event1))
|
||||
@@ -1211,11 +1212,11 @@ describe('JSONL immutable generation publication', () => {
|
||||
const request = options(root)
|
||||
const expected = join(root, 'expected.jsonl')
|
||||
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
||||
await writeFile(expected, line(header(3)) + line(event0))
|
||||
await writeFile(expected, line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
await link(expected, request.currentPath)
|
||||
|
||||
await expect(ensureJsonlGenerationCurrent(request)).resolves.toMatchObject({ path: request.currentPath })
|
||||
expect(await readFile(expected, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(expected, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it.each(['different', 'malformed', 'symlink', 'directory'] as const)(
|
||||
@@ -1225,7 +1226,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
const request = options(root)
|
||||
const source = Buffer.from(line(header(0)) + line(event0))
|
||||
await writeFile(request.sourcePath, source)
|
||||
if (kind === 'different') await writeFile(request.currentPath, line(header(3)) + line(event1))
|
||||
if (kind === 'different') await writeFile(request.currentPath, line(header(SESSION_FORMAT_VERSION)) + line(event1))
|
||||
if (kind === 'malformed') await writeFile(request.currentPath, '{not-json}\n')
|
||||
if (kind === 'symlink') await symlink(request.sourcePath, request.currentPath)
|
||||
if (kind === 'directory') await mkdir(request.currentPath)
|
||||
@@ -1252,7 +1253,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
},
|
||||
}
|
||||
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
||||
await writeFile(request.currentPath, line(header(3)) + line(event0))
|
||||
await writeFile(request.currentPath, line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
|
||||
const failure = await ensureJsonlGenerationCurrent(request).then(
|
||||
() => undefined,
|
||||
@@ -1281,10 +1282,10 @@ describe('JSONL immutable generation publication', () => {
|
||||
request,
|
||||
{ platform: 'darwin', fs: { open: openFile } },
|
||||
)).rejects.toBe(directorySyncFailure)
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', 'session.v3.jsonl'])
|
||||
expect((await readdir(root)).sort()).toEqual(['session.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
|
||||
await expect(ensureJsonlGenerationCurrent(request)).resolves.toMatchObject({ path: request.currentPath })
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('retains a committed generation when its post-publication stat fails', async () => {
|
||||
@@ -1302,7 +1303,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
},
|
||||
},
|
||||
})).rejects.toBe(statFailure)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1318,7 +1319,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
if (phase === 'after-publication') controller.abort(reason)
|
||||
},
|
||||
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('rejects a noncanonical case-insensitive collision instead of accepting its bytes', async () => {
|
||||
@@ -1330,12 +1331,12 @@ describe('JSONL immutable generation publication', () => {
|
||||
platform: 'darwin',
|
||||
fs: posixSimulationFs({
|
||||
link: async () => { throw fsError('EEXIST') },
|
||||
readdir: async () => ['session.V3.jsonl'],
|
||||
readdir: async () => [`session.V${SESSION_FORMAT_VERSION}.jsonl`],
|
||||
}),
|
||||
}).then(() => undefined, (error: unknown) => error)
|
||||
|
||||
if (!(failure instanceof JsonlGenerationTargetConflictError)) throw new Error('expected target conflict')
|
||||
expect(failure.reason.message).toContain('noncanonical directory entry "session.V3.jsonl"')
|
||||
expect(failure.reason.message).toContain(`noncanonical directory entry "session.V${SESSION_FORMAT_VERSION}.jsonl"`)
|
||||
expect((await readdir(root)).every(name => !name.includes('.tmp'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1365,7 +1366,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
},
|
||||
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
||||
expect(reads).not.toContain(request.currentPath)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('leaves a crash-style staging file inert', async () => {
|
||||
@@ -1378,7 +1379,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
await ensureJsonlGenerationCurrent(request)
|
||||
|
||||
expect(await readFile(crashStage, 'utf8')).toBe(line(header(99)))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('removes an exclusively created stage when writing or syncing it fails', async () => {
|
||||
@@ -1499,7 +1500,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
}),
|
||||
})).resolves.toMatchObject({ status: 'migrated', path: request.currentPath })
|
||||
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('surfaces candidate validation errors and cleanup errors without publishing', async () => {
|
||||
@@ -1534,7 +1535,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
if (!path.includes('.tmp')) return bytes
|
||||
if (mode === 'torn') return bytes.subarray(0, -1)
|
||||
if (mode === 'old') return Buffer.from(line(header(0)) + line(event0))
|
||||
return Buffer.from(line(header(3)) + '{not-json}\n')
|
||||
return Buffer.from(line(header(SESSION_FORMAT_VERSION)) + '{not-json}\n')
|
||||
}
|
||||
|
||||
await expect(ensureWithOverrides(
|
||||
@@ -1544,7 +1545,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
mode === 'torn'
|
||||
? 'current session generation has a torn physical tail'
|
||||
: mode === 'old'
|
||||
? 'uses log format v0, older than the supported v3'
|
||||
? `uses log format v0, older than the supported v${SESSION_FORMAT_VERSION}`
|
||||
: 'unparsable committed event at line 1',
|
||||
)
|
||||
expect(await readdir(root)).toEqual(['session.jsonl'])
|
||||
@@ -1563,7 +1564,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect(publishNewWin32).toHaveBeenCalledOnce()
|
||||
expect(publishNewWin32.mock.calls[0]?.[1]).toBe(request.currentPath)
|
||||
expect(await readFile(request.sourcePath)).toEqual(source)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('accepts an identical target that wins Windows publication', async () => {
|
||||
@@ -1571,7 +1572,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
const request = options(root)
|
||||
await writeFile(request.sourcePath, line(header(0)) + line(event0))
|
||||
const publishNewWin32 = vi.fn(async (_from: string, to: string) => {
|
||||
await writeFile(to, line(header(3)) + line(event0))
|
||||
await writeFile(to, line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
throw fsError('EEXIST')
|
||||
})
|
||||
|
||||
@@ -1614,7 +1615,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
)
|
||||
|
||||
expect(raced).toBe(true)
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(3)) + line(event0))
|
||||
expect(await readFile(request.currentPath, 'utf8')).toBe(line(header(SESSION_FORMAT_VERSION)) + line(event0))
|
||||
})
|
||||
|
||||
it('honors cancellation before reading a generation', async () => {
|
||||
|
||||
@@ -439,7 +439,7 @@ describe('JsonlSessionPersistence: format helpers', () => {
|
||||
['unseeded', false, 0],
|
||||
['empty-seed', true, 0],
|
||||
['nonempty-seed', true, 3],
|
||||
] as const)('round-trips v3 lineage markers for %s', (_case, isSeeded, inheritedEventCount) => {
|
||||
] as const)('round-trips current lineage markers for %s', (_case, isSeeded, inheritedEventCount) => {
|
||||
const events = isSeeded
|
||||
? [
|
||||
...Array.from({ length: inheritedEventCount }, (_, seq) => ({
|
||||
@@ -470,7 +470,7 @@ describe('JsonlSessionPersistence: format helpers', () => {
|
||||
expect(toHeaderLine(scanned.meta, scanned.inheritedEventCount)).toStrictEqual(line)
|
||||
})
|
||||
|
||||
it('rejects a v3 physical header without explicit isSeeded', () => {
|
||||
it('rejects a current physical header without explicit isSeeded', () => {
|
||||
const { isSeeded: _isSeeded, ...line } = toHeaderLine(meta('missing-is-seeded'))
|
||||
expect(() => scanLog(Buffer.from(`${JSON.stringify(line)}\n`))).toThrow(/session header/)
|
||||
})
|
||||
@@ -698,7 +698,6 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
afterEach(async () => { await ctx.fiber.dispose() })
|
||||
|
||||
it('projects a released v0 header through stat and list without reading or mutating its body', async () => {
|
||||
expect(SESSION_FORMAT_VERSION).toBe(3)
|
||||
const header = meta('released-v0-metadata', '/work')
|
||||
const sourcePath = historicalLogPath(root, header.cwd, header.id)
|
||||
const currentPath = rawLogPath(root, header.cwd, header.id)
|
||||
@@ -972,12 +971,12 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
.toEqual(['session.v1.jsonl'])
|
||||
})
|
||||
|
||||
it('selects v1 from a v0/v1 directory, then v3 from the retained three-generation set', async () => {
|
||||
it('selects v1 from a v0/v1 directory, then the current generation from the retained three-generation set', async () => {
|
||||
const header = meta('mixed-generation-read', '/work')
|
||||
const directory = sessionDir(root, header.cwd, header.id)
|
||||
const v0Path = historicalLogPath(root, header.cwd, header.id)
|
||||
const v1Path = generationLogPath(root, header.cwd, header.id, 1, 'none')
|
||||
const v3Path = rawLogPath(root, header.cwd, header.id)
|
||||
const currentPath = rawLogPath(root, header.cwd, header.id)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(v0Path, `${JSON.stringify(releasedV0Header(header))}\n`)
|
||||
await writeFile(v1Path, releasedV1PackedPhysicalLog(header))
|
||||
@@ -988,13 +987,13 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
await writer.close()
|
||||
expect((await readdir(directory)).filter(name => name.startsWith('session')).sort())
|
||||
.toEqual(process.platform === 'win32'
|
||||
? ['session.jsonl', 'session.v1.jsonl', 'session.v3.jsonl']
|
||||
: ['session.jsonl', 'session.lock', 'session.v1.jsonl', 'session.v3.jsonl'])
|
||||
? ['session.jsonl', 'session.v1.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`]
|
||||
: ['session.jsonl', 'session.lock', 'session.v1.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
|
||||
await writeFile(v0Path, 'corrupt lower v0\n')
|
||||
await writeFile(v1Path, 'corrupt lower v1\n')
|
||||
await expect(readAll(ctx.sessionPersistence, header.id)).resolves.toEqual(migrated)
|
||||
expect(await readFile(v3Path, 'utf8')).toContain('"version":3')
|
||||
expect(await readFile(currentPath, 'utf8')).toContain(`"version":${SESSION_FORMAT_VERSION}`)
|
||||
})
|
||||
|
||||
it('does not publish a historical generation through handle storage resolution', async () => {
|
||||
@@ -1073,7 +1072,7 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
const writer = await ctx.sessionPersistence.open(header.id, 'write')
|
||||
await writer.close()
|
||||
expect(await readFile(sourcePath, 'utf8')).toBe(`${source}\n`)
|
||||
expect(await readFile(currentPath, 'utf8')).toContain('"version":3')
|
||||
expect(await readFile(currentPath, 'utf8')).toContain(`"version":${SESSION_FORMAT_VERSION}`)
|
||||
})
|
||||
|
||||
it('finishes publication before rejecting a write open cancelled during publication', async () => {
|
||||
@@ -1089,7 +1088,7 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
|
||||
|
||||
await expect(ctx.sessionPersistence.open(header.id, 'write', { signal: controller.signal }))
|
||||
.rejects.toBe(reason)
|
||||
expect(await readFile(currentPath, 'utf8')).toContain('"version":3')
|
||||
expect(await readFile(currentPath, 'utf8')).toContain(`"version":${SESSION_FORMAT_VERSION}`)
|
||||
const writer = await ctx.sessionPersistence.open(header.id, 'write')
|
||||
await writer.close()
|
||||
})
|
||||
@@ -2012,7 +2011,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => {
|
||||
expect(restored).toMatchObject({ type: 'user/message', surfaceOp: 'append', sourceEventSeqs: [0, 1] })
|
||||
})
|
||||
|
||||
it('requires the tagged inherited cut to agree with the v3 header lineage', () => {
|
||||
it('requires the tagged inherited cut to agree with the current header lineage', () => {
|
||||
const seeded = { ...meta('scanner-seeded-cut'), isSeeded: true }
|
||||
const seededHeader = JSON.stringify(toHeaderLine(seeded, SessionLogOffset(0)))
|
||||
expect(() => scanLog(Buffer.from(`${seededHeader}\n`)))
|
||||
@@ -2234,7 +2233,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('JsonlSessionPersistence: nested v3 Assistant streams', () => {
|
||||
describe('JsonlSessionPersistence: nested Assistant streams', () => {
|
||||
let ctx: Context
|
||||
beforeEach(async () => {
|
||||
root = await freshRoot()
|
||||
@@ -2287,7 +2286,7 @@ describe('JsonlSessionPersistence: nested v3 Assistant streams', () => {
|
||||
expect(loaded.events).toEqual(log)
|
||||
})
|
||||
|
||||
it.each([2, 3])('reads v%s rows and appends a v3 turn without changing predecessor bytes', async (version) => {
|
||||
it.each([2, 3])('reads v%s rows and appends a current turn without changing predecessor bytes', async (version) => {
|
||||
const m = meta('mixed', '/work')
|
||||
const log = chunkRunLog()
|
||||
const sourcePath = generationLogPath(root, '/work', m.id, version, 'none')
|
||||
@@ -2304,10 +2303,10 @@ describe('JsonlSessionPersistence: nested v3 Assistant streams', () => {
|
||||
|
||||
const expected = version === 2 ? withMigratedEmptyHead(log) : log
|
||||
const restored = await readAll(ctx.sessionPersistence, m.id)
|
||||
expect(restored.meta.version).toBe(3)
|
||||
expect(restored.meta.version).toBe(SESSION_FORMAT_VERSION)
|
||||
expect(restored.events).toEqual(expected)
|
||||
expect(await readFile(sourcePath)).toEqual(source)
|
||||
if (version === 2) await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
const secondTurn: SessionEvent[] = JSON.parse(JSON.stringify(log)) as SessionEvent[]
|
||||
for (const [k, e] of secondTurn.entries()) {
|
||||
;(e as { seq: number }).seq = expected.length + k
|
||||
@@ -2317,11 +2316,11 @@ describe('JsonlSessionPersistence: nested v3 Assistant streams', () => {
|
||||
|
||||
const loaded = await readAll(ctx.sessionPersistence, m.id)
|
||||
expect(loaded.events).toEqual([...expected, ...secondTurn])
|
||||
expect(currentPath).toBe(join(dirname(sourcePath), 'session.v3.jsonl'))
|
||||
expect(currentPath).toBe(join(dirname(sourcePath), `session.v${SESSION_FORMAT_VERSION}.jsonl`))
|
||||
const successor = (await readFile(currentPath, 'utf8')).trimEnd().split('\n')
|
||||
expect(JSON.parse(successor[0] as string)).toMatchObject({ version: 3 })
|
||||
expect(JSON.parse(successor[0] as string)).toMatchObject({ version: SESSION_FORMAT_VERSION })
|
||||
expect(successor.slice(1).map(row => JSON.parse(row) as unknown)).toEqual([...expected, ...secondTurn])
|
||||
if (version === 2) expect(await readFile(sourcePath)).toEqual(source)
|
||||
expect(await readFile(sourcePath)).toEqual(source)
|
||||
// Compact tags stay nested; physical rows contain only current event tags.
|
||||
const tags = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
|
||||
.map(line => (JSON.parse(line) as { type: string }).type)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Durable EOF refusals preserve historical generations and never fall back from native V3. */
|
||||
/** Durable EOF refusals preserve historical generations and never fall back from the selected generation. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, 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'
|
||||
@@ -116,7 +116,11 @@ function line(value: unknown): string {
|
||||
return JSON.stringify(value) + '\n'
|
||||
}
|
||||
|
||||
async function store(version: 2 | 3, compression: JsonlCompression, rows: readonly SessionFormatJsonObject[]) {
|
||||
async function store(
|
||||
version: 2 | 3 | typeof SESSION_FORMAT_VERSION,
|
||||
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 }))
|
||||
@@ -162,38 +166,43 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres
|
||||
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' })
|
||||
for (const targetVersion of [3, SESSION_FORMAT_VERSION]) {
|
||||
await expect(stat(generationLogPath(root, undefined, id, targetVersion, 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)
|
||||
it.each(nativeRefusals.flatMap(refusal => ([3, SESSION_FORMAT_VERSION] as const).map(version => ({ ...refusal, version }))))(
|
||||
'refuses V$version $name instead of falling back to readable V2', async ({ tail, diagnostic, version }) => {
|
||||
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(SESSION_FORMAT_VERSION)
|
||||
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, path])
|
||||
}
|
||||
})
|
||||
await expectOnlyGenerations([lowerPath])
|
||||
|
||||
const path = await store(version, compression, [...nativePrefix, tail])
|
||||
const original = await observe(path)
|
||||
const message = diagnostic + (version < SESSION_FORMAT_VERSION ? `; source v${version} artifact remains unchanged` : '')
|
||||
+ ' (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])
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 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 { SESSION_FORMAT_VERSION, 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'
|
||||
@@ -150,7 +150,7 @@ function assertRequests(events: readonly SessionEvent[], header: SessionHeader)
|
||||
|
||||
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(header).toEqual({ version: SESSION_FORMAT_VERSION, 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',
|
||||
@@ -200,7 +200,7 @@ async function publishedRows(path: string, compression: JsonlCompression) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
it.each(['none', 'zstd'] as const)('publishes only the current generation 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)
|
||||
@@ -215,7 +215,7 @@ describe.each([0, 1] as const)('V%s multi-edge durable publication', (version) =
|
||||
expect(written.events).toEqual(prepared.events)
|
||||
await ctx.fiber.dispose()
|
||||
contexts.splice(contexts.indexOf(ctx), 1)
|
||||
const successor = generationLogPath(root, undefined, id, 3, compression)
|
||||
const successor = generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, 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])
|
||||
@@ -263,7 +263,7 @@ describe.each([0, 1] as const)('V%s multi-edge durable publication', (version) =
|
||||
expect(retried.events).toEqual(expected)
|
||||
expect(retried.cut).toBe(prepared.cut)
|
||||
assertRequests(retried.events, retried.header)
|
||||
const successor = generationLogPath(root, undefined, id, 3, compression)
|
||||
const successor = generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, 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())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Real JSONL publication and provider-neutral message preservation across the V2 PTC rename. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { createHash } from 'node:crypto'
|
||||
@@ -117,21 +117,21 @@ describe('JSONL V2 PTC publication and restore', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes V3 without changing V2 bytes and preserves restored IDs, roles, content, and replay state', async () => {
|
||||
it('publishes the current generation without changing V2 bytes and preserves restored IDs, roles, content, and replay state', async () => {
|
||||
if (root === undefined || ctx === undefined) throw new Error('persistence fixture is not initialized')
|
||||
const id = SessionId('tools-code-mode:session')
|
||||
const header = { type: 'session', version: 2, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
|
||||
const events = releasedV2Events()
|
||||
const source = Buffer.from([header, ...events].map(row => JSON.stringify(row)).join('\n') + '\n')
|
||||
const predecessor = generationLogPath(root, undefined, id, 2, 'none')
|
||||
const successor = join(dirname(predecessor), 'session.v3.jsonl')
|
||||
const successor = join(dirname(predecessor), `session.v${SESSION_FORMAT_VERSION}.jsonl`)
|
||||
await mkdir(dirname(predecessor), { recursive: true })
|
||||
await writeFile(predecessor, source)
|
||||
const sourceStat = await stat(predecessor)
|
||||
|
||||
const reader = await ctx.sessionPersistence.open(id, 'read')
|
||||
try {
|
||||
expect(reader.header).toEqual({ version: 3, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 })
|
||||
expect(reader.header).toEqual({ version: SESSION_FORMAT_VERSION, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 })
|
||||
expect((await reader.read()).events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'step/start', 'system/message', 'user/message', 'assistant/message', 'tool/call',
|
||||
'tool/ptc-dispatch-start', 'tool/ptc-dispatch', 'tool/result', 'agent/inbox/spliced',
|
||||
@@ -153,7 +153,7 @@ describe('JSONL V2 PTC publication and restore', () => {
|
||||
const published = await readFile(successor)
|
||||
const [publishedHeader, ...publishedEvents] = published.toString('utf8').trimEnd().split('\n')
|
||||
.map((row): unknown => JSON.parse(row))
|
||||
expect(publishedHeader).toEqual({ ...header, version: 3 })
|
||||
expect(publishedHeader).toEqual({ ...header, version: SESSION_FORMAT_VERSION })
|
||||
const expectedEvents: SessionFormatEvent[] = events.map(event => ({ ...event, seq: event.seq < 2 ? event.seq : event.seq + 1 }))
|
||||
expectedEvents[5] = { ...expectedEvents[5], type: 'tool/ptc-dispatch-start' } as SessionFormatEvent
|
||||
expectedEvents[6] = { ...expectedEvents[6], type: 'tool/ptc-dispatch' } as SessionFormatEvent
|
||||
@@ -201,6 +201,6 @@ describe('JSONL V2 PTC publication and restore', () => {
|
||||
expect(await stat(predecessor)).toMatchObject({ dev: sourceStat.dev, ino: sourceStat.ino })
|
||||
expect(await readFile(successor)).toEqual(published)
|
||||
expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl')).sort())
|
||||
.toEqual(['session.v2.jsonl', 'session.v3.jsonl'])
|
||||
.toEqual(['session.v2.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -95,7 +95,7 @@ function assertRequestHistory(events: readonly SessionEvent[], handle: SessionHa
|
||||
}
|
||||
|
||||
describe('V2 system prompts through current Session and JSONL persistence', () => {
|
||||
it.each([undefined, ''])('prepares prompts and clear (%j) read-only, then publishes V3 without replacing V2', async (clear) => {
|
||||
it.each([undefined, ''])('prepares prompts and clear (%j) read-only, then publishes the current generation without replacing V2', async (clear) => {
|
||||
const sourcePath = await writeV2([
|
||||
{ type: 'turn/start', data: { turn: 1 } },
|
||||
{ type: 'step/start', data: { turn: 1, step: 1 } },
|
||||
@@ -110,7 +110,7 @@ describe('V2 system prompts through current Session and JSONL persistence', () =
|
||||
const reader = await ctx.sessionPersistence.open(id, 'read')
|
||||
let prepared: readonly SessionEvent[]
|
||||
try {
|
||||
expect(reader.header.version).toBe(3)
|
||||
expect(reader.header.version).toBe(SESSION_FORMAT_VERSION)
|
||||
prepared = (await reader.read()).events
|
||||
expect(prepared.map(event => event.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
|
||||
expect(prepared.filter(event => event.type !== 'system/message').map(event => [event.type, event.time])).toEqual([
|
||||
@@ -156,10 +156,10 @@ describe('V2 system prompts through current Session and JSONL persistence', () =
|
||||
}
|
||||
expect(await observeFile(sourcePath)).toEqual(original)
|
||||
expect((await readdir(directory)).filter(name => name.endsWith('.jsonl')).sort())
|
||||
.toEqual(['session.v2.jsonl', 'session.v3.jsonl'])
|
||||
const publishedPath = generationLogPath(root, undefined, id, 3, 'none')
|
||||
.toEqual(['session.v2.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`])
|
||||
const publishedPath = generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, 'none')
|
||||
const published = (await readFile(publishedPath, 'utf8')).trimEnd().split('\n').map(line => JSON.parse(line) as unknown)
|
||||
expect(published[0]).toMatchObject({ type: 'session', version: 3, id })
|
||||
expect(published[0]).toMatchObject({ type: 'session', version: SESSION_FORMAT_VERSION, id })
|
||||
expect(published.slice(1)).toEqual(prepared)
|
||||
|
||||
const reopened = await mount()
|
||||
@@ -268,7 +268,7 @@ describe('V2 system prompts through current Session and JSONL persistence', () =
|
||||
.toEqual(['session.v1.jsonl', 'session.v2.jsonl'])
|
||||
})
|
||||
|
||||
it('persists native V3 system appends after the protected head without converting them to user messages', async () => {
|
||||
it('persists current system appends after the protected head without converting them to user messages', async () => {
|
||||
const ctx = await mount()
|
||||
const session = Session.create(id)
|
||||
const writer = await ctx.sessionPersistence.create(session.header)
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"path": "../session-format-catalog"
|
||||
},
|
||||
{
|
||||
"path": "../session-format-v2-to-v3"
|
||||
"path": "../session-format-v3-to-v4"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,7 +61,7 @@ const COMPACTION_ID = CompactionId('replay-compaction')
|
||||
/** Build a minimal session-JSONL string: a header line + the given events. */
|
||||
function sessionJsonl(
|
||||
events: SessionEvent[],
|
||||
header?: { id?: string; createdAt?: number; seedLength?: number; version?: 0 | 1 | 2 | 3 },
|
||||
header?: { id?: string; createdAt?: number; seedLength?: number; version?: 0 | 1 | 2 | 3 | 4 },
|
||||
): string {
|
||||
const version = header?.version ?? 0
|
||||
const headerLine = JSON.stringify({
|
||||
@@ -79,7 +79,7 @@ function sessionJsonl(
|
||||
/** Build a valid one-turn Session around recorded model calls. */
|
||||
function replaySessionJsonl(
|
||||
calls: readonly StreamChunk[][],
|
||||
header?: { id?: string; createdAt?: number; seedLength?: number; version?: 0 | 1 | 2 | 3 },
|
||||
header?: { id?: string; createdAt?: number; seedLength?: number; version?: 0 | 1 | 2 | 3 | 4 },
|
||||
): string {
|
||||
const version = header?.version ?? SESSION_FORMAT_VERSION
|
||||
const events: SessionEvent[] = []
|
||||
|
||||
@@ -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/test-support/session-snapshot/README.md
|
||||
README.md: 51aaff9af7dfce83ed032b42ce0bb471aa384002
|
||||
README.zh.md: ad136b19a75170c130bbb872ba02bb88e010bb02
|
||||
README.md: 14305a6a27de79690537895a632f9796b4b7d17b
|
||||
README.zh.md: 7e3d8db31f67a670b026bc4e95eb8e481a2f95eb
|
||||
@@ -68,13 +68,13 @@ defineAcpSnapshotSuite({
|
||||
})
|
||||
```
|
||||
|
||||
Each recorded-session directory carries a closed `snapshot.yml` manifest plus canonical parent and contiguous child roles. Parent filenames are `session[.vN].jsonl`; children are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions use lowercase `.vN`, and every filename agrees with its header. A role may retain older generations, but the harness selects the numerically highest one. An owning manifest may declare `sessionFormat.version` plus one or more closed `coverage` names to keep that historical generation as an explicit migration fixture; absence tracks the current writer. The manifest also names the scenario, shipped profile, composition/header class, recording source, and only the replay, platform, permission, environment, workspace, or input facts the completed Session cannot reconstruct. Storage guards check tool results and portable paths in every selected parent and child role. Prompt/schema scrubbing, message identity, and prompt-before-request ordering apply to current generations; retained predecessors keep their historical representation. The adapter registers expected-output, Session-log, and optional `workspace.expected/` comparisons; guards reject orphan directories, missing roles, noncanonical names, absolute paths, malformed manifests, and platform-specific separators.
|
||||
Each recorded-session directory carries a closed `snapshot.yml` manifest plus canonical parent and contiguous child roles. Parent filenames are `session[.vN].jsonl`; children are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions use lowercase `.vN`, and every filename agrees with its header. A role may retain older generations, but the harness selects the numerically highest one. An owning manifest may declare `sessionFormat.version` plus one or more closed `coverage` names to keep that historical generation as an explicit migration fixture. Without that declaration, record and refresh write the current generation; replay also accepts the retained V3 baseline under the [corpus policy](../../../snapshots/AGENTS.md). The manifest also names the scenario, shipped profile, composition/header class, recording source, and only the replay, platform, permission, environment, workspace, or input facts the completed Session cannot reconstruct. Storage guards check tool results and portable paths in every selected parent and child role. Prompt/schema scrubbing, message identity, and prompt-before-request ordering apply to current generations; retained predecessors keep their historical representation. The adapter registers expected-output, Session-log, and optional `workspace.expected/` comparisons; guards reject orphan directories, missing roles, noncanonical names, absolute paths, malformed manifests, and platform-specific separators.
|
||||
|
||||
`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing system-prompt text and tool schemas; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes, and catalog child-creation clocks. Event order and source-event references are preserved. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison validates expected and harvested logs through the strict build-static Session format catalog before identity redaction and normalization; source filenames cannot alter format validation. A retained historical replay input is not the native current-format writer-output oracle: structural migration preserves request meaning but can produce a different event layout. Normalization preserves unexpected request-header fields, including `system`, so regressions remain visible. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Fixtures in the [current writer format](../../../docs/session-format-status.md) use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion.
|
||||
|
||||
Spill scenarios save through the real local provider under a private temporary root. Their fixture adapter exposes fixed-length logical locators and maps only this run’s saved locators back to real files for retrieval, preserving preview budgets without writing into a shared logical path. Known snapshot spill paths normalize to stable locator tokens, including paths quoted inside JSON omission notices with JSON-escaped Windows separators. Refresh extraction preserves the matched serialized path spelling for literal replacement. Normalization changes only the locator: saved byte lengths and omission counts remain comparison evidence.
|
||||
|
||||
Retained historical scenarios keep their canonical Session files unchanged and selected for replay, with no newer canonical sibling in the pinned directory. Their exact normalized native current-format output is recorded separately in `writer.expected.jsonl` for the parent and `writer.<ordinal>.expected.jsonl` for children; these are output oracles, not replay generations. Retained SDK scenarios use `notifications.current.expected.jsonl` for current protocol output. Comparisons neither project current events back into a historical format nor strip structural differences. Independent migration tests verify the official transform rather than treating native writer layout as its expected event sequence.
|
||||
Retained historical scenarios keep their canonical Session files unchanged and selected for replay, with no newer canonical sibling in the pinned directory. Their exact normalized native writer output is recorded separately in `writer.expected.jsonl` for the parent and `writer.<ordinal>.expected.jsonl` for children; these are output oracles, not replay generations. Output oracles may retain an older supported generation: comparison restores them through the strict catalog, while harvested logs must use the checkout writer. Fresh-writer golden comparisons select `nativeWriterOutput` to tokenize only delivery markers qualified by each input’s original writer generation after strict restoration. Other delivery generations and captured-source fields remain numeric; source-versus-migrated-artifact comparisons preserve exact generations by default. Recording and refresh never store these comparison tokens. Retained SDK scenarios use `notifications.current.expected.jsonl` for current protocol output. Comparisons neither project current events back into a historical format nor strip structural differences. Independent migration tests verify the official transform rather than treating native writer layout as its expected event sequence.
|
||||
|
||||
### Record, replay, and refresh
|
||||
|
||||
|
||||
@@ -68,13 +68,13 @@ defineAcpSnapshotSuite({
|
||||
})
|
||||
```
|
||||
|
||||
每个已记录 Session 目录携带封闭的 `snapshot.yml` manifest,以及规范 parent 与连续 child 角色。parent 文件名是 `session[.vN].jsonl`;child 是 `session.<ordinal>[.vN].jsonl`;v0 省略 `.v0`,正版本使用小写 `.vN`,且每个文件名与其 header 一致。一个角色可以保留旧 generation,但 harness 会选择数值最高的一项。拥有 fixture 的 manifest 可以声明 `sessionFormat.version` 与一个或多个封闭 `coverage` 名称,把该历史 generation 保留为显式迁移 fixture;省略此字段时跟随当前 writer。manifest 还会指名场景、随附 profile、组合/header 类别、录制来源,以及已完成 Session 无法重建的 replay、平台、权限、环境、workspace 或输入事实。存储保护检查每个选定 parent 与 child 角色的工具结果和可移植路径。提示词/schema 擦除、消息身份及提示词先于请求的顺序检查适用于当前 generation;保留的前代维持其历史表示。适配器注册预期输出、Session 日志与可选 `workspace.expected/` 比较;保护会拒绝遗留目录、缺失角色、非规范名称、绝对路径、格式错误的 manifest 与平台专用分隔符。
|
||||
每个已记录 Session 目录携带封闭的 `snapshot.yml` manifest,以及规范 parent 与连续 child 角色。parent 文件名是 `session[.vN].jsonl`;child 是 `session.<ordinal>[.vN].jsonl`;v0 省略 `.v0`,正版本使用小写 `.vN`,且每个文件名与其 header 一致。一个角色可以保留旧 generation,但 harness 会选择数值最高的一项。拥有 fixture 的 manifest 可以声明 `sessionFormat.version` 与一个或多个封闭 `coverage` 名称,把该历史 generation 保留为显式迁移 fixture。未声明时,record 与 refresh 写入当前 generation;replay 也按[语料策略](../../../snapshots/AGENTS.md)接受保留的 V3 基线。manifest 还会指名场景、随附 profile、组合/header 类别、录制来源,以及已完成 Session 无法重建的 replay、平台、权限、环境、workspace 或输入事实。存储保护检查每个选定 parent 与 child 角色的工具结果和可移植路径。提示词/schema 擦除、消息身份及提示词先于请求的顺序检查适用于当前 generation;保留的前代维持其历史表示。适配器注册预期输出、Session 日志与可选 `workspace.expected/` 比较;保护会拒绝遗留目录、缺失角色、非规范名称、绝对路径、格式错误的 manifest 与平台专用分隔符。
|
||||
|
||||
`normalizeSessionSnapshot` 在规范化路径并擦除系统提示文本与工具 schema 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope 与 catalog child 创建时钟。事件顺序与来源事件引用保持不变。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过严格的构建期静态 Session 格式目录校验预期日志与收集日志,再进行身份脱敏与规范化;来源文件名不能改变格式校验。保留的历史 replay 输入不是原生当前格式 writer 输出的比较基准:结构迁移保留请求含义,但可以产生不同的事件布局。归一化保留意外的 request-header 字段(包括 `system`),使回归保持可见。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。[当前写入器格式](../../../docs/session-format-status.zh.md)的 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。
|
||||
|
||||
spill 场景通过真实本地提供方保存到私有临时根目录。fixture 适配器提供固定长度的逻辑定位符,并仅将本次运行已保存的定位符映射回实际文件以供检索,在不写入共享逻辑路径的情况下保留预览预算。已知的快照 spill 路径会规范化为稳定的定位符 token,包括 JSON 省略通知中带引号、使用 JSON 转义 Windows 分隔符的路径。刷新提取会保留匹配路径的序列化写法,以便进行字面替换。规范化只改变定位符:保存字节数与省略计数仍作为比较证据。
|
||||
|
||||
保留历史输入的场景保持规范 Session 文件不变,并继续选择它们进行回放;固定历史版本的目录中没有更新的规范同角色文件。其精确的规范化原生当前格式输出单独记录在父会话的 `writer.expected.jsonl` 和子会话的 `writer.<ordinal>.expected.jsonl` 中;这些是输出比较基准,而非 replay 代际。保留历史输入的 SDK 场景使用 `notifications.current.expected.jsonl` 记录当前协议输出。比较既不将当前事件反向投影为历史格式,也不剥除结构差异。独立迁移测试验证正式转换,而不把原生 writer 布局当作其预期事件序列。
|
||||
保留历史输入的场景保持规范 Session 文件不变,并继续选择它们进行回放;固定历史版本的目录中没有更新的规范同角色文件。其精确的规范化原生 writer 输出单独记录在父会话的 `writer.expected.jsonl` 和子会话的 `writer.<ordinal>.expected.jsonl` 中;这些是输出比较基准,而非 replay 代际。输出比较基准可以保留较旧的受支持代际:比较通过严格 catalog 恢复它们,而收集的日志必须使用当前检出版本的 writer。新 writer 输出的 golden 比较选择 `nativeWriterOutput`,仅在严格恢复后,将版本限定为各输入原始 writer 代际的 delivery 标记转换为 token。其他 delivery 代际与 captured-source 字段保持数值;源文件与已迁移产物的比较默认保留精确代际。record 与 refresh 从不存储这些比较 token。保留历史输入的 SDK 场景使用 `notifications.current.expected.jsonl` 记录当前协议输出。比较既不将当前事件反向投影为历史格式,也不剥除结构差异。独立迁移测试验证正式转换,而不把原生 writer 布局当作其预期事件序列。
|
||||
|
||||
### 录制、回放与刷新
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { redactSessionSnapshotIds } from './identity.ts'
|
||||
import { sessionHeaderVersion } from './session-files.ts'
|
||||
|
||||
const SESSION_ID = '{{sessionId}}'
|
||||
const MESSAGE_ID = '{{messageId}}'
|
||||
@@ -20,6 +21,7 @@ const SYSTEM = '{{system}}'
|
||||
const TOOLS = '{{tools}}'
|
||||
const EVENT_TIME = '{{eventTime}}'
|
||||
const EVENT_OMITTED_BYTES = '{{eventOmittedBytes}}'
|
||||
const SOURCE_SESSION_FORMAT = '{{sourceSessionFormatVersion}}'
|
||||
const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
||||
|
||||
function isPackedFixtureRow(record: Record<string, unknown>): boolean {
|
||||
@@ -105,6 +107,12 @@ export interface NormalizeOptions {
|
||||
identityMode?: 'legacy' | 'preserve'
|
||||
}
|
||||
|
||||
/** Comparison controls for complete primary and child Session logs. */
|
||||
export interface SessionSnapshotComparisonOptions extends Omit<NormalizeOptions, 'identityMode'> {
|
||||
/** Inputs are captured native writer output, rather than source-versus-migrated-artifact comparisons. */
|
||||
nativeWriterOutput?: true
|
||||
}
|
||||
|
||||
/** Return every known spelling of the generated cwd, most specific first. */
|
||||
function cwdSpellings(ctx: NormalizeContext): string[] {
|
||||
const spellings = [...new Set([ctx.cwd, ...ctx.cwdAliases ?? []])]
|
||||
@@ -432,41 +440,52 @@ export function normalizeSessionSnapshot(
|
||||
|
||||
/**
|
||||
* Normalize one scenario's primary and child logs with shared typed identity redaction.
|
||||
* Native-writer comparison strictly restores each input before tokenizing its own delivery generation.
|
||||
* @param rawLogs - primary-first persisted or projected session JSONL.
|
||||
* @param ctx - generated cwd spellings and other volatile run facts.
|
||||
* @param options - separator controls; relationship-preserving identity mode is mandatory.
|
||||
* @returns normalized session fixtures in input order.
|
||||
* @param options - separator and native-writer comparison controls; identity relationships are preserved.
|
||||
* @returns comparison-only Session records in input order; not persistence or fixture write-back input.
|
||||
*/
|
||||
export function normalizeSessionSnapshots(
|
||||
rawLogs: readonly string[],
|
||||
ctx: NormalizeContext,
|
||||
options: Omit<NormalizeOptions, 'identityMode'> = {},
|
||||
options: SessionSnapshotComparisonOptions = {},
|
||||
): string[] {
|
||||
const currentLogs = rawLogs.map(log => hasSessionFormatVersion(log)
|
||||
? prepareSessionSnapshotFixtureForComparison(log)
|
||||
: log)
|
||||
const comparableLogs = currentLogs.map(normalizeSessionFormatMetadata)
|
||||
const { nativeWriterOutput, ...normalizeOptions } = options
|
||||
const comparableLogs = rawLogs.map((log) => {
|
||||
if (!hasSessionFormatVersion(log)) return normalizeSessionFormatMetadata(log)
|
||||
const currentLog = prepareSessionSnapshotFixtureForComparison(log)
|
||||
return normalizeSessionFormatMetadata(currentLog, nativeWriterOutput
|
||||
? sessionHeaderVersion(log, 'source Session snapshot') : undefined)
|
||||
})
|
||||
return redactSessionSnapshotIds(comparableLogs).map(log => projectSessionSnapshot(
|
||||
scrubSessionSnapshot(normalizeSessionLog(
|
||||
log,
|
||||
{ ...ctx, sessionIds: [] },
|
||||
{ ...options, identityMode: 'preserve' },
|
||||
{ ...normalizeOptions, identityMode: 'preserve' },
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Omit the artifact header generation after official migration for comparison.
|
||||
* Delivery and captured-source generations retain their opaque recorded values.
|
||||
* An explicit source version tokenizes only delivery markers for that original input generation.
|
||||
* Other delivery generations and every captured-source generation retain their recorded values.
|
||||
* @param rawLog - Session records or events as compact JSON lines.
|
||||
* @returns the same records with only the Session header version omitted.
|
||||
* @param sourceVersion - original generation of strictly validated native writer output; otherwise omit.
|
||||
* @returns comparison-only records with the Session header version omitted and native delivery qualifiers tokenized.
|
||||
*/
|
||||
export function normalizeSessionFormatMetadata(rawLog: string): string {
|
||||
export function normalizeSessionFormatMetadata(rawLog: string, sourceVersion?: number): string {
|
||||
return rawLog.split('\n').map((line) => {
|
||||
if (line.trim().length === 0) return line
|
||||
const record = JSON.parse(line) as Record<string, unknown>
|
||||
if (record.type !== 'session' || !Object.hasOwn(record, 'version')) return line
|
||||
delete record.version
|
||||
if (record.type === 'session' && Object.hasOwn(record, 'version')) {
|
||||
delete record.version
|
||||
} else if (sourceVersion !== undefined && record.type === 'session-log-deepseek/delivery-accepted') {
|
||||
const data = record.data as Record<string, unknown> | undefined
|
||||
if (data?.sessionFormatVersion !== sourceVersion) return line
|
||||
data.sessionFormatVersion = SOURCE_SESSION_FORMAT
|
||||
} else return line
|
||||
return JSON.stringify(record)
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
@@ -1439,8 +1439,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
sessionIds: fixtureContexts.flatMap(context => context.sessionIds),
|
||||
cwd: (fixtureContexts[0] as NormalizeContext).cwd,
|
||||
}
|
||||
const actualSnapshots = normalizeSessionSnapshots(harvested, ctx)
|
||||
const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureCtx)
|
||||
const actualSnapshots = normalizeSessionSnapshots(harvested, ctx, { nativeWriterOutput: true })
|
||||
const expectedSnapshots = normalizeSessionSnapshots(fixtures, fixtureCtx, { nativeWriterOutput: true })
|
||||
for (const [index, actual] of actualSnapshots.entries()) {
|
||||
expect(actual, `${fixtureFiles[index]} mismatch`).toEqual(expectedSnapshots[index])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay'
|
||||
import {
|
||||
type NormalizeContext,
|
||||
extractSnapshotSpillPaths,
|
||||
@@ -753,7 +755,60 @@ describe('normalizeSessionSnapshot', () => {
|
||||
.toThrow('session snapshot must start with a session header')
|
||||
})
|
||||
|
||||
it('preserves delivery and captured-source generations after artifact migration', () => {
|
||||
function deliveryLog(version: number, deliveryVersion = version): string {
|
||||
return [
|
||||
{ type: 'session', version, id: 'delivery', createdAt: 1, isSeeded: false, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{
|
||||
type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
|
||||
data: { sessionId: 'delivery', throughSeq: 0, sessionFormatVersion: deliveryVersion },
|
||||
},
|
||||
].map(record => JSON.stringify(record)).join('\n') + '\n'
|
||||
}
|
||||
|
||||
it('compares each strictly restored input using its own native delivery generation', () => {
|
||||
const [historical, current] = normalizeSessionSnapshots(
|
||||
[deliveryLog(3), deliveryLog(SESSION_FORMAT_VERSION)], ctx, { nativeWriterOutput: true },
|
||||
)
|
||||
expect(historical).toBe(current)
|
||||
expect(current).toContain('"sessionFormatVersion":"{{sourceSessionFormatVersion}}"')
|
||||
expect(normalizeSessionSnapshot(deliveryLog(SESSION_FORMAT_VERSION), ctx))
|
||||
.toContain(`"sessionFormatVersion":${SESSION_FORMAT_VERSION}`)
|
||||
})
|
||||
|
||||
it('keeps source-versus-migrated-artifact delivery comparison generation-exact by default', () => {
|
||||
const source = deliveryLog(3)
|
||||
const target = prepareSessionSnapshotFixtureForComparison(source)
|
||||
const [historical, migrated] = normalizeSessionSnapshots([source, target], ctx)
|
||||
expect(historical).toBe(migrated)
|
||||
expect(migrated).toContain('"sessionFormatVersion":3')
|
||||
})
|
||||
|
||||
it('keeps a wrong native delivery generation distinguishable from historical writer output', () => {
|
||||
const [historical, staleCurrent] = normalizeSessionSnapshots(
|
||||
[deliveryLog(3), deliveryLog(SESSION_FORMAT_VERSION, 3)], ctx, { nativeWriterOutput: true },
|
||||
)
|
||||
expect(staleCurrent).not.toBe(historical)
|
||||
expect(staleCurrent).toContain('"sessionFormatVersion":3')
|
||||
})
|
||||
|
||||
it('refuses a source marker claiming the migration target before creating comparison tokens', () => {
|
||||
expect(() => normalizeSessionSnapshots([deliveryLog(3, 4)], ctx, { nativeWriterOutput: true }))
|
||||
.toThrow('format v3 delivery marker claims target format v4')
|
||||
})
|
||||
|
||||
it('keeps captured generations and delivery lookalikes numeric in versioned inputs', () => {
|
||||
const log = deliveryLog(SESSION_FORMAT_VERSION) + JSON.stringify({
|
||||
type: 'custom/event', seq: 2, time: 3, ignorable: true,
|
||||
data: { capturedFormatVersion: SESSION_FORMAT_VERSION, sessionFormatVersion: SESSION_FORMAT_VERSION },
|
||||
}) + '\n'
|
||||
const [normalized] = normalizeSessionSnapshots([log], ctx, { nativeWriterOutput: true })
|
||||
expect(normalized).toContain(JSON.stringify({
|
||||
capturedFormatVersion: SESSION_FORMAT_VERSION, sessionFormatVersion: SESSION_FORMAT_VERSION,
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves delivery and captured-source generations without a validated source generation', () => {
|
||||
const event = (version: number): string => JSON.stringify({
|
||||
type: 'session-log-deepseek/delivery-accepted',
|
||||
data: { sessionId: 's', throughSeq: 4, sessionFormatVersion: version },
|
||||
|
||||
Generated
+18
-2
@@ -9030,6 +9030,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-format-v2-to-v3':
|
||||
specifier: workspace:^
|
||||
version: link:../session-format-v2-to-v3
|
||||
'@deepseek-ai/dsh-session-format-v3-to-v4':
|
||||
specifier: workspace:^
|
||||
version: link:../session-format-v3-to-v4
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
@@ -9092,6 +9095,19 @@ importers:
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/session/session-format-v3-to-v4:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-session-format':
|
||||
specifier: workspace:^
|
||||
version: link:../session-format
|
||||
'@deepseek-ai/dsh-session-format-v2-to-v3':
|
||||
specifier: workspace:^
|
||||
version: link:../session-format-v2-to-v3
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/session/session-log-deepseek:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-brand':
|
||||
@@ -9174,9 +9190,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-format-catalog':
|
||||
specifier: workspace:^
|
||||
version: link:../session-format-catalog
|
||||
'@deepseek-ai/dsh-session-format-v2-to-v3':
|
||||
'@deepseek-ai/dsh-session-format-v3-to-v4':
|
||||
specifier: workspace:^
|
||||
version: link:../session-format-v2-to-v3
|
||||
version: link:../session-format-v3-to-v4
|
||||
'@deepseek-ai/node-addon-system':
|
||||
specifier: workspace:^
|
||||
version: link:../../../native/system/packages/entry
|
||||
|
||||
@@ -14,6 +14,10 @@ from deepseek_harness import RunResult
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SMOKE = runpy.run_path(ROOT / "scripts" / "smoke-python-runtime.py")
|
||||
SESSION_FORMAT_VERSION = int(
|
||||
(ROOT / "packages/core/session/src/types.ts").read_text(encoding="utf-8")
|
||||
.split("export const SESSION_FORMAT_VERSION = ", 1)[1].splitlines()[0]
|
||||
)
|
||||
|
||||
|
||||
def live_result(**overrides: object) -> RunResult:
|
||||
@@ -460,21 +464,22 @@ def test_snapshot_generation_names_select_highest_role_without_double_counting(
|
||||
}
|
||||
|
||||
|
||||
def test_snapshot_comparison_accepts_v3_output_against_v2_without_rewriting(tmp_path: Path) -> None:
|
||||
def test_snapshot_comparison_accepts_current_output_against_v2_without_rewriting(tmp_path: Path) -> None:
|
||||
predecessor = '{"type":"session","version":2}\n'
|
||||
successor = '{"type":"session","version":3}\n'
|
||||
successor = json.dumps({"type": "session", "version": SESSION_FORMAT_VERSION}) + "\n"
|
||||
successor_name = f"session.v{SESSION_FORMAT_VERSION}.jsonl"
|
||||
old_path = tmp_path / "session.v2.jsonl"
|
||||
old_path.write_text(predecessor, encoding="utf-8")
|
||||
files = {"session.v3.jsonl": successor}
|
||||
files = {successor_name: successor}
|
||||
|
||||
SMOKE["compare_snapshot_files"](files, False, tmp_path, ("session.v2.jsonl",))
|
||||
assert old_path.read_text(encoding="utf-8") == predecessor
|
||||
assert not (tmp_path / "session.v3.jsonl").exists()
|
||||
assert not (tmp_path / successor_name).exists()
|
||||
|
||||
SMOKE["compare_snapshot_files"](files, True, tmp_path, ("session.v2.jsonl",))
|
||||
assert old_path.read_text(encoding="utf-8") == predecessor
|
||||
assert (tmp_path / "session.v3.jsonl").read_text(encoding="utf-8") == successor
|
||||
assert SMOKE["selected_snapshot_session_files"](tmp_path) == {0: tmp_path / "session.v3.jsonl"}
|
||||
assert (tmp_path / successor_name).read_text(encoding="utf-8") == successor
|
||||
assert SMOKE["selected_snapshot_session_files"](tmp_path) == {0: tmp_path / successor_name}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filenames", [
|
||||
@@ -485,7 +490,7 @@ def test_snapshot_comparison_accepts_v3_output_against_v2_without_rewriting(tmp_
|
||||
def test_snapshot_builder_checks_role_order_and_count_across_generations(
|
||||
tmp_path: Path, filenames: tuple[str, ...],
|
||||
) -> None:
|
||||
files = {"session.v3.jsonl": "", "session.1.v3.jsonl": ""}
|
||||
files = {f"session.v{SESSION_FORMAT_VERSION}.jsonl": "", f"session.1.v{SESSION_FORMAT_VERSION}.jsonl": ""}
|
||||
with pytest.raises(AssertionError, match="snapshot builder produced"):
|
||||
SMOKE["compare_snapshot_files"](files, False, tmp_path, filenames)
|
||||
|
||||
@@ -496,12 +501,14 @@ def test_snapshot_generation_comparison_rejects_changed_payload(tmp_path: Path)
|
||||
)
|
||||
with pytest.raises(AssertionError, match="executable snapshot mismatch"):
|
||||
SMOKE["compare_snapshot_files"](
|
||||
{"session.v3.jsonl": '{"type":"session","version":3,"id":"changed"}\n'},
|
||||
{f"session.v{SESSION_FORMAT_VERSION}.jsonl": json.dumps({
|
||||
"type": "session", "version": SESSION_FORMAT_VERSION, "id": "changed",
|
||||
}) + "\n"},
|
||||
False, tmp_path, ("session.v2.jsonl",),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [2, 4])
|
||||
@pytest.mark.parametrize("version", [SESSION_FORMAT_VERSION - 1, SESSION_FORMAT_VERSION + 1])
|
||||
@pytest.mark.parametrize("update", [False, True])
|
||||
def test_snapshot_comparison_rejects_noncurrent_writer(
|
||||
tmp_path: Path, version: int, update: bool,
|
||||
@@ -509,22 +516,22 @@ def test_snapshot_comparison_rejects_noncurrent_writer(
|
||||
golden = '{"type":"session","version":2}\n'
|
||||
(tmp_path / "session.v2.jsonl").write_text(golden, encoding="utf-8")
|
||||
content = json.dumps({"type": "session", "version": version}) + "\n"
|
||||
with pytest.raises(AssertionError, match="expected current Session format v3"):
|
||||
with pytest.raises(AssertionError, match=f"expected current Session format v{SESSION_FORMAT_VERSION}"):
|
||||
SMOKE["compare_snapshot_files"](
|
||||
{f"session.v{version}.jsonl": content}, update, tmp_path, ("session.v2.jsonl",),
|
||||
)
|
||||
assert (tmp_path / "session.v2.jsonl").read_text(encoding="utf-8") == golden
|
||||
assert not (tmp_path / "session.v4.jsonl").exists()
|
||||
assert not (tmp_path / f"session.v{SESSION_FORMAT_VERSION}.jsonl").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [2, 3, 4])
|
||||
@pytest.mark.parametrize("version", [SESSION_FORMAT_VERSION - 1, SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION + 1])
|
||||
def test_persisted_session_requires_current_writer(version: int) -> None:
|
||||
content = json.dumps({"type": "session", "version": version}) + "\n"
|
||||
path = Path(f"session.v{version}.jsonl")
|
||||
if version == 3:
|
||||
if version == SESSION_FORMAT_VERSION:
|
||||
assert SMOKE["assert_persisted_session_version"](path, content) == version
|
||||
else:
|
||||
with pytest.raises(AssertionError, match="expected current Session format v3"):
|
||||
with pytest.raises(AssertionError, match=f"expected current Session format v{SESSION_FORMAT_VERSION}"):
|
||||
SMOKE["assert_persisted_session_version"](path, content)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/session/session-format-catalog': 'Generated build-static Session format inventory with no plugin registration.',
|
||||
'packages/session/session-format-v0-to-v1': 'Pure released-v0 codec and adjacent migration library.',
|
||||
'packages/session/session-format-v2-to-v3': 'Pure released-v2 codec and adjacent migration library.',
|
||||
'packages/session/session-format-v3-to-v4': 'Released V3 codec reuse and adjacent migration library.',
|
||||
'packages/session/session-telemetry': 'Telemetry Service Definition and capture library; providers mount the backend.',
|
||||
'packages/session/session-title-llm': 'Shared LLM title-provider registration and request policy.',
|
||||
'packages/subagent/subagent-in-process-driver': 'Shared one-shot child-agent driver used by provider plugins.',
|
||||
|
||||
@@ -209,9 +209,13 @@ export function renderSessionFormatCatalog(
|
||||
'/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */',
|
||||
'export const sessionFormatCatalog = createSessionFormatCatalog({',
|
||||
` currentVersion: ${currentVersion},`,
|
||||
` codecs: [${codecs.join(', ')}],`,
|
||||
' codecs: [',
|
||||
...codecs.map(codec => ` ${codec},`),
|
||||
' ],',
|
||||
` currentEncoder: ${currentCodec},`,
|
||||
` migrations: [${declarations.map(item => item.migration).join(', ')}],`,
|
||||
' migrations: [',
|
||||
...declarations.map(item => ` ${item.migration},`),
|
||||
' ],',
|
||||
' restoreCurrent(artifact) {',
|
||||
` const restored = ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`,
|
||||
' validateInstalledCurrentSessionArtifact(restored)',
|
||||
|
||||
@@ -11,17 +11,18 @@ const completeV0 = {
|
||||
},
|
||||
} as const
|
||||
|
||||
const adjacent = Array.from({ length: SESSION_FORMAT_VERSION - 1 }, (_, index) => ({
|
||||
key: `sdk/v${index + 1}`,
|
||||
selectedVersions: [index + 1],
|
||||
retained: { version: index + 1, coverage: ['adjacent-migration'] as const },
|
||||
const adjacent = [1, 2].map(version => ({
|
||||
key: `sdk/v${version}`,
|
||||
selectedVersions: [version],
|
||||
retained: { version, coverage: ['adjacent-migration'] as const },
|
||||
}))
|
||||
const baseline = { key: 'session/v3', selectedVersions: Array<number>(8).fill(3) }
|
||||
const current = { key: 'session/current', selectedVersions: Array<number>(8).fill(SESSION_FORMAT_VERSION) }
|
||||
|
||||
describe('recorded-session corpus policy', () => {
|
||||
it('accepts a current majority and complete bounded migration coverage', () => {
|
||||
it('keeps a V3 majority as migration input without requiring current-generation successors', () => {
|
||||
expect(assertSnapshotCorpusPolicy([
|
||||
current,
|
||||
baseline,
|
||||
{
|
||||
key: 'session/multi-hop',
|
||||
selectedVersions: [0, 0, 0],
|
||||
@@ -30,23 +31,33 @@ describe('recorded-session corpus policy', () => {
|
||||
{ key: 'session/packed', selectedVersions: [0], retained: { version: 0, coverage: ['packed-row'] } },
|
||||
{ key: 'session/retry', selectedVersions: [0], retained: { version: 0, coverage: ['retry-failure'] } },
|
||||
...adjacent,
|
||||
])).toEqual({ currentRoles: 8, retainedRoles: 5 + adjacent.length, retainedScenarios: 3 + adjacent.length })
|
||||
])).toEqual({ currentRoles: 0, baselineRoles: 8, retainedRoles: 5 + adjacent.length, retainedScenarios: 3 + adjacent.length })
|
||||
})
|
||||
|
||||
it('accepts current-writer owners alongside retained V3 replay input', () => {
|
||||
expect(assertSnapshotCorpusPolicy([baseline, current, completeV0, ...adjacent]))
|
||||
.toEqual({ currentRoles: 8, baselineRoles: 8, retainedRoles: 3, retainedScenarios: 3 })
|
||||
})
|
||||
|
||||
it('rejects refreshing away every direct V3 migration input', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([current, completeV0, ...adjacent]))
|
||||
.toThrow('Session corpus lacks v3 coverage: adjacent-migration')
|
||||
})
|
||||
|
||||
it('requires v0 coverage from v0 fixtures', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([current, ...adjacent]))
|
||||
expect(() => assertSnapshotCorpusPolicy([baseline, ...adjacent]))
|
||||
.toThrow('Session corpus lacks v0 coverage')
|
||||
})
|
||||
|
||||
it.each(adjacent)('requires direct coverage from $key', (fixture) => {
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
current, completeV0, ...adjacent.filter(other => other !== fixture),
|
||||
baseline, completeV0, ...adjacent.filter(other => other !== fixture),
|
||||
])).toThrow(`Session corpus lacks v${fixture.retained.version} coverage: adjacent-migration`)
|
||||
})
|
||||
|
||||
it('does not let one retained generation claim another edge\'s coverage', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
current,
|
||||
baseline,
|
||||
completeV0,
|
||||
{
|
||||
key: 'sdk/adjacent',
|
||||
@@ -63,9 +74,16 @@ describe('recorded-session corpus policy', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/empty', selectedVersions: [] }]))
|
||||
.toThrow('session/empty: scenario owns no selected Session role')
|
||||
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/old', selectedVersions: [1] }]))
|
||||
.toThrow(`session/old: selected Session generation v1 does not match expected v${SESSION_FORMAT_VERSION}`)
|
||||
.toThrow(`session/old: selected Session generation v1 must be retained baseline v3 or current v${SESSION_FORMAT_VERSION}`)
|
||||
expect(() => assertSnapshotCorpusPolicy([{ ...completeV0, selectedVersions: [0, SESSION_FORMAT_VERSION] }]))
|
||||
.toThrow(`session/v0: selected Session generation v${SESSION_FORMAT_VERSION} does not match expected v0`)
|
||||
expect(() => assertSnapshotCorpusPolicy([{ ...baseline, selectedVersions: [3, SESSION_FORMAT_VERSION] }]))
|
||||
.toThrow(`session/v3: selected Session generation v${SESSION_FORMAT_VERSION} does not match expected v3`)
|
||||
})
|
||||
|
||||
it('rejects future unpinned generations', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/future', selectedVersions: [SESSION_FORMAT_VERSION + 1] }]))
|
||||
.toThrow(`session/future: selected Session generation v${SESSION_FORMAT_VERSION + 1} must be retained baseline v3 or current v${SESSION_FORMAT_VERSION}`)
|
||||
})
|
||||
|
||||
it.each([SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION + 1, -1, 0.5])(
|
||||
@@ -77,14 +95,15 @@ describe('recorded-session corpus policy', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('bounds historical roles and requires a current majority', () => {
|
||||
it('bounds explicitly retained roles and requires a baseline/current majority', () => {
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
{ ...current, selectedVersions: Array<number>(20).fill(SESSION_FORMAT_VERSION) },
|
||||
baseline,
|
||||
current,
|
||||
{ ...completeV0, selectedVersions: Array<number>(11).fill(0) },
|
||||
...adjacent,
|
||||
])).toThrow(`Session corpus retains ${11 + adjacent.length} historical roles; maximum is 10`)
|
||||
expect(() => assertSnapshotCorpusPolicy([
|
||||
{ ...current, selectedVersions: [SESSION_FORMAT_VERSION] }, completeV0, ...adjacent,
|
||||
])).toThrow(`Session corpus requires a current majority; current=1, retained=${1 + adjacent.length}`)
|
||||
{ ...baseline, selectedVersions: [3] }, completeV0, ...adjacent,
|
||||
])).toThrow(`Session corpus requires a baseline/current majority; baseline=1, current=0, retained=${1 + adjacent.length}`)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Enforced current-writer majority and retained migration coverage. */
|
||||
/** Retained V3 replay inputs, current-writer fixtures, and bounded older migration coverage. */
|
||||
|
||||
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import type { SnapshotSessionFormatManifest } from '@deepseek-ai/dsh-session-snapshot'
|
||||
@@ -16,10 +16,12 @@ export interface SnapshotCorpusScenarioGenerations {
|
||||
/** Counts returned after the corpus policy accepts the inventory. */
|
||||
export interface SnapshotCorpusGenerationSummary {
|
||||
readonly currentRoles: number
|
||||
readonly baselineRoles: number
|
||||
readonly retainedRoles: number
|
||||
readonly retainedScenarios: number
|
||||
}
|
||||
|
||||
const RETAINED_BASELINE_VERSION = 3
|
||||
const MAX_RETAINED_ROLES = 10
|
||||
const REQUIRED_V0_COVERAGE = new Set([
|
||||
'multi-hop',
|
||||
@@ -30,24 +32,26 @@ const REQUIRED_V0_COVERAGE = new Set([
|
||||
const REQUIRED_ADJACENT_COVERAGE = new Set(['adjacent-migration'])
|
||||
|
||||
/**
|
||||
* Require a current-writer majority and bounded coverage of each released migration source.
|
||||
* Require a baseline/current majority and direct coverage of each historical migration source.
|
||||
*
|
||||
* @param scenarios - Every owning top-level recorded-session scenario.
|
||||
* @returns Accepted current and retained role counts.
|
||||
* @returns Accepted current, baseline, and explicitly retained role counts.
|
||||
*/
|
||||
export function assertSnapshotCorpusPolicy(
|
||||
scenarios: readonly SnapshotCorpusScenarioGenerations[],
|
||||
): SnapshotCorpusGenerationSummary {
|
||||
let currentRoles = 0
|
||||
let baselineRoles = 0
|
||||
let retainedRoles = 0
|
||||
let retainedScenarios = 0
|
||||
const coverageByVersion = new Map<number, Set<string>>()
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.selectedVersions.length === 0) {
|
||||
const selectedVersion = scenario.selectedVersions[0]
|
||||
if (selectedVersion === undefined) {
|
||||
throw new Error(`${scenario.key}: scenario owns no selected Session role`)
|
||||
}
|
||||
const expectedVersion = scenario.retained?.version ?? SESSION_FORMAT_VERSION
|
||||
const expectedVersion = scenario.retained?.version ?? selectedVersion
|
||||
const mismatched = scenario.selectedVersions.find(version => version !== expectedVersion)
|
||||
if (mismatched !== undefined) {
|
||||
throw new Error(
|
||||
@@ -55,7 +59,15 @@ export function assertSnapshotCorpusPolicy(
|
||||
)
|
||||
}
|
||||
if (scenario.retained === undefined) {
|
||||
currentRoles += scenario.selectedVersions.length
|
||||
if (selectedVersion === RETAINED_BASELINE_VERSION) {
|
||||
baselineRoles += scenario.selectedVersions.length
|
||||
} else if (selectedVersion === SESSION_FORMAT_VERSION) {
|
||||
currentRoles += scenario.selectedVersions.length
|
||||
} else {
|
||||
throw new Error(
|
||||
`${scenario.key}: selected Session generation v${selectedVersion} must be retained baseline v${RETAINED_BASELINE_VERSION} or current v${SESSION_FORMAT_VERSION}`,
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!Number.isSafeInteger(scenario.retained.version)
|
||||
@@ -77,6 +89,7 @@ export function assertSnapshotCorpusPolicy(
|
||||
for (const item of scenario.retained.coverage) coverage.add(item)
|
||||
}
|
||||
|
||||
if (baselineRoles > 0) coverageByVersion.set(RETAINED_BASELINE_VERSION, new Set(REQUIRED_ADJACENT_COVERAGE))
|
||||
for (let version = 0; version < SESSION_FORMAT_VERSION; version += 1) {
|
||||
const required = version === 0 ? REQUIRED_V0_COVERAGE : REQUIRED_ADJACENT_COVERAGE
|
||||
const coverage = coverageByVersion.get(version)
|
||||
@@ -88,10 +101,10 @@ export function assertSnapshotCorpusPolicy(
|
||||
if (retainedRoles > MAX_RETAINED_ROLES) {
|
||||
throw new Error(`Session corpus retains ${retainedRoles} historical roles; maximum is ${MAX_RETAINED_ROLES}`)
|
||||
}
|
||||
if (currentRoles <= retainedRoles) {
|
||||
if (baselineRoles + currentRoles <= retainedRoles) {
|
||||
throw new Error(
|
||||
`Session corpus requires a current majority; current=${currentRoles}, retained=${retainedRoles}`,
|
||||
`Session corpus requires a baseline/current majority; baseline=${baselineRoles}, current=${currentRoles}, retained=${retainedRoles}`,
|
||||
)
|
||||
}
|
||||
return { currentRoles, retainedRoles, retainedScenarios }
|
||||
return { currentRoles, baselineRoles, retainedRoles, retainedScenarios }
|
||||
}
|
||||
@@ -191,7 +191,7 @@ it('keeps every recorded session owned, pinned, redacted, and header-scrubbed',
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a current-writer majority plus bounded declared historical migration coverage', async () => {
|
||||
it('keeps V3 replay input plus bounded declared historical migration coverage', async () => {
|
||||
const owners = (await scenarios()).filter(scenario => scenario.manifest.session === undefined)
|
||||
const inventory = await Promise.all(owners.map(async scenario => ({
|
||||
key: scenario.key,
|
||||
@@ -201,7 +201,9 @@ it('keeps a current-writer majority plus bounded declared historical migration c
|
||||
: { retained: scenario.manifest.sessionFormat }),
|
||||
})))
|
||||
|
||||
expect(assertSnapshotCorpusPolicy(inventory)).toMatchObject({
|
||||
const summary = assertSnapshotCorpusPolicy(inventory)
|
||||
expect(summary.baselineRoles).toBeGreaterThan(0)
|
||||
expect(summary).toMatchObject({
|
||||
retainedRoles: 9,
|
||||
retainedScenarios: 7,
|
||||
})
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ Every process under test starts through the `dsh` CLI with a shipped profile and
|
||||
|
||||
Each scenario owns or explicitly references one primary Session role plus contiguous child roles. Canonical parent filenames are `session[.vN].jsonl`; children are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions use lowercase `.vN`, and every filename agrees with its header. A directory may retain several generations of one role, but replay, record, and refresh select the numerically highest. Record and refresh write version-named outputs without renaming or deleting a committed generation; reviewed source-tree curation is separate. The owner alone records or refreshes the selected role. For an ordinary one-shot case, derive the user task and replay script from that selected JSONL; do not duplicate them in an `input.json`. Shared references are read-only, acyclic, point to the owner's selected parent generation, and are used only when another interface intentionally renders the same recorded behavior.
|
||||
|
||||
An owner that must keep a historical generation declares its exact `sessionFormat.version` and closed migration `coverage` names in `snapshot.yml`; record and refresh never rewrite that Session fixture. Owners without this declaration track current-writer output.
|
||||
An owner that must keep a historical generation declares its exact `sessionFormat.version` and closed migration `coverage` names in `snapshot.yml`; record and refresh never rewrite that Session fixture. Owners without this declaration write current-writer output during record and refresh; replay accepts the retained V3 baseline or the current generation, with one selected generation per owner. The [corpus policy](../scripts/session-snapshot-corpus-policy.ts) requires V3 inputs for direct upgrade coverage and explicit V0–V2 migration coverage; a writer bump alone does not refresh fixtures.
|
||||
|
||||
Committed sessions are normalization fixed points. Replace volatile identities with typed relationship-preserving tokens, replace request system prompts and tool schemas with tokens, and keep exactly one readable sidecar owner per header class. Never redact arbitrary user or tool text merely because it resembles an identifier.
|
||||
|
||||
|
||||
@@ -408,6 +408,17 @@ function records(log: string): JsonObject[] {
|
||||
.map(line => JSON.parse(line) as JsonObject)
|
||||
}
|
||||
|
||||
function notificationComparisonRecords(log: string, sourceLogs: readonly string[]): JsonObject[] {
|
||||
const versions = sourceLogs.map((source, index) => sessionHeaderVersion(source, `notification Session ${index}`))
|
||||
expect(new Set(versions).size, 'wire golden Session roles share one native writer generation').toBe(1)
|
||||
return records(log).map((record) => {
|
||||
if (record.method !== 'session.event') return record
|
||||
const params = record.params as JsonObject
|
||||
const event = JSON.parse(normalizeSessionFormatMetadata(JSON.stringify(params.event), versions[0])) as unknown
|
||||
return { ...record, params: { ...params, event } }
|
||||
})
|
||||
}
|
||||
|
||||
function modelFromSession(log: string): { provider: string; model: string } {
|
||||
for (const record of records(log)) {
|
||||
if (record.type !== 'request/header') continue
|
||||
@@ -885,14 +896,14 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
|
||||
expect(await Promise.all((await fixtureFiles(scenario)).map(file => readFile(file, 'utf8'))),
|
||||
'historical replay input remains unchanged').toEqual(replayContents)
|
||||
for (const [index, content] of expectedContents.entries()) {
|
||||
expect(sessionHeaderVersion(content, writerSnapshotName(index))).toBe(SESSION_FORMAT_VERSION)
|
||||
expect(sessionHeaderVersion(content, writerSnapshotName(index))).toBeLessThanOrEqual(SESSION_FORMAT_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted transcripts match the committed fixtures.
|
||||
const expectedContext = contextOfContents(expectedContents)
|
||||
const actualSnapshots = normalizeSessionSnapshots(ordered.map(log => log.content), actualContext)
|
||||
const expectedSnapshots = normalizeSessionSnapshots(expectedContents, expectedContext)
|
||||
const actualSnapshots = normalizeSessionSnapshots(ordered.map(log => log.content), actualContext, { nativeWriterOutput: true })
|
||||
const expectedSnapshots = normalizeSessionSnapshots(expectedContents, expectedContext, { nativeWriterOutput: true })
|
||||
expect(actualSnapshots.map(records), `${scenario.name}: sessions`).toEqual(expectedSnapshots.map(records))
|
||||
await verifyHeaders(scenario, ordered, actualContext, assertions.dshSdkChild?.agentConfig)
|
||||
|
||||
@@ -908,10 +919,10 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => {
|
||||
}
|
||||
const expectedNotifications = await readFile(notificationsExpectedPath, 'utf8')
|
||||
expect(
|
||||
records(normalizedNotifications),
|
||||
notificationComparisonRecords(normalizedNotifications, ordered.map(log => log.content)),
|
||||
`${scenario.name}: notifications`,
|
||||
)
|
||||
.toEqual(records(expectedNotifications))
|
||||
.toEqual(notificationComparisonRecords(expectedNotifications, expectedContents))
|
||||
expect(normalizedResult).toBe(await readFile(resultExpectedPath, 'utf8'))
|
||||
}
|
||||
|
||||
|
||||
@@ -1153,12 +1153,12 @@ describe('headless recorded-session snapshots', () => {
|
||||
if (mode === 'refresh') await writeSessionFixtures(scenario, actualLogs, fixtures, actualContext)
|
||||
expected = await Promise.all(fixtures.map((_, index) => readFile(join(scenario.dir, writerSnapshotName(index)), 'utf8')))
|
||||
for (const [index, content] of expected.entries()) {
|
||||
expect(sessionHeaderVersion(content, writerSnapshotName(index))).toBe(SESSION_FORMAT_VERSION)
|
||||
expect(sessionHeaderVersion(content, writerSnapshotName(index))).toBeLessThanOrEqual(SESSION_FORMAT_VERSION)
|
||||
}
|
||||
expect(await fixtureSessions(scenario), 'historical replay input remains unchanged').toEqual(fixtures)
|
||||
}
|
||||
const actualSnapshots = normalizeSessionSnapshots(actualLogs.map(log => log.content), actualContext)
|
||||
const expectedSnapshots = normalizeSessionSnapshots(expected, contextOf(expected))
|
||||
const actualSnapshots = normalizeSessionSnapshots(actualLogs.map(log => log.content), actualContext, { nativeWriterOutput: true })
|
||||
const expectedSnapshots = normalizeSessionSnapshots(expected, contextOf(expected), { nativeWriterOutput: true })
|
||||
for (const [index, actual] of actualSnapshots.entries()) {
|
||||
expect(records(actual), `${scenario.name}: session ${index}`).toEqual(records(expectedSnapshots[index] as string))
|
||||
}
|
||||
|
||||
@@ -394,6 +394,7 @@
|
||||
"@deepseek-ai/dsh-session-format-v0-to-v1": ["./packages/session/session-format-v0-to-v1/src"],
|
||||
"@deepseek-ai/dsh-session-format-v1-to-v2": ["./packages/session/session-format-v1-to-v2/src"],
|
||||
"@deepseek-ai/dsh-session-format-v2-to-v3": ["./packages/session/session-format-v2-to-v3/src"],
|
||||
"@deepseek-ai/dsh-session-format-v3-to-v4": ["./packages/session/session-format-v3-to-v4/src"],
|
||||
"@deepseek-ai/dsh-session-log-deepseek": ["./packages/session/session-log-deepseek/src"],
|
||||
"@deepseek-ai/dsh-session-log-deepseek/invariant": ["./packages/session/session-log-deepseek/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-session-log-export": ["./packages/session-query/session-log-export/src"],
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
{ "path": "./packages/session/session-format-v0-to-v1" },
|
||||
{ "path": "./packages/session/session-format-v1-to-v2" },
|
||||
{ "path": "./packages/session/session-format-v2-to-v3" },
|
||||
{ "path": "./packages/session/session-format-v3-to-v4" },
|
||||
{ "path": "./packages/session/session-format-catalog" },
|
||||
{ "path": "./packages/session/session-checkpoint-policy" },
|
||||
{ "path": "./packages/session/session-log-deepseek" },
|
||||
|
||||
Reference in new issue
Block a user