feat(session): default JSONL writes to packed rows

This commit is contained in:
Tianyi Cui
2026-07-26 23:44:52 +08:00
parent cad2af4741
commit 4ff496c65c
47 changed files with 521 additions and 251 deletions

View File

@@ -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
2026-06-14-session-persistence.md: 52434930bb662b0c97e61f7c2f69b67c309b6317
2026-06-14-session-persistence.zh.md: 143b58d32191108d7ba24b489bd4f898b1547aab
2026-06-14-session-persistence.md: 75e13b860f621ed407849b3b4c62ff7287ab4812
2026-06-14-session-persistence.zh.md: a6bd400a053779c742940236737447d1687622de

View File

@@ -15,11 +15,11 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append
Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic:
1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session (a `SessionHeader` line then one `SessionEvent` per line, verbatim **including `assistant/chunk`**), encoded as [checksummed Zstandard frames by default](2026-07-19-zstandard-jsonl-session-logs.md) or raw lines by configuration.
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session: a `SessionHeader` line followed by storage records that losslessly represent the contiguous `SessionEvent` stream. Eligible `assistant/chunk` delta runs use packed rows by default; [checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
Key choices recorded here because they are durable, contested, and surprising:
- **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but `load` reconstructs the exact event boundaries, sequence numbers, and timestamps. `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* logical log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)``append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. Its database carries a dedicated application id and monotonic schema version. A pristine file creates all tables and stamps both header values in one transaction; an unversioned file with any user-defined schema object or application identity, a foreign current-version identity, and every non-current version reject before journal-mode mutation.
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, JSONL validates the decoded header, and SQLite stores it in a strict `INTEGER` column. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
@@ -33,4 +33,4 @@ Format versioning: the header carries a `version`; `load` rejects any non-curren
## Consequences
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open.
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every `assistant/chunk` survives exactly even when JSONL packs several into one storage row. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open.

View File

@@ -15,11 +15,11 @@ Status: implemented
持久化是一个抽象的**能力 seam**[能力 seam](2026-06-13-capability-seams.md)`dsh-bash` 模板),而非循环或核心逻辑:
1. **接口**`dsh-session-persistence``ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent``{ type, seq, time, data }`),原样复用,无转换类型。
2. **实现**`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志一行 `SessionHeader`之后每行一个 `SessionEvent`,逐字节保留,**包括 `assistant/chunk`**),默认编码为[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md),也可通过配置使用原始行。
2. **实现**`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码,也可通过配置使用原始行。
以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的:
- **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过分片而过滤分片的方案Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但 `load` 会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片而过滤分片的方案Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏契约和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在模型分发前排空请求、在工具分发前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的 assistant 调用追加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }``turn/end`。合成的结果保证恢复后的提供方 transcript文本记录仍然有效。只有不完整的最后一条记录会被丢弃在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)``append` 是 INSERT在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类接口无变化opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq一次表达在文件字节上一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()``createdAt` 是以 Unix epoch 毫秒表示的非负安全整数运行时创建和持久化注册会拒绝小数值JSONL 会验证解码后的 headerSQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。)
@@ -33,4 +33,4 @@ Status: implemented
## 后果
新增两个包package以及 `dsh-session` 中的元数据 seam`session.header``create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度`assistant/chunk` 保持逐字节不变。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。
新增两个包package以及 `dsh-session` 中的元数据 seam`session.header``create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。

View File

@@ -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
2026-07-26-packed-chunk-rows-by-default.md: a4ac43280f83fdb1a75057d8a0d5633c33b89b36
2026-07-26-packed-chunk-rows-by-default.zh.md: 05909c5f8aecc9f57c8145f87f9c908fd2118867
2026-07-26-packed-chunk-rows-by-default.md: e1090264238ff15670a58ee33b062ad340241b8e
2026-07-26-packed-chunk-rows-by-default.zh.md: b193e37987946764d6c19583f2e3f195ae31bf61

View File

@@ -0,0 +1,59 @@
# Agent Note: Make packed chunk rows the default JSONL layout
Status: implemented
English | [中文](2026-07-26-packed-chunk-rows-by-default.zh.md)
## Problem
Provider streams produce many token-sized `assistant/chunk` delta events whose repeated JSON envelopes can outweigh their payloads. The session log must retain each chunk as a distinct logical event: live `session/event` delivery, sequence numbers, `sourceEventSeqs`, replay, cancellation evidence, and UI streaming all depend on those boundaries.
The JSONL storage seam can reduce that envelope cost without changing the logical log. A run of at least three consecutive same-block delta events fits in one `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` storage row, and decoding reconstructs every original event, timestamp, and sequence number. A credible default must cover runtime writers, app-level config, snapshot producers, and committed fixtures together; otherwise tests avoid the layout that deployments write.
## Decision
`dsh-session-persistence-jsonl` resolves an omitted `packChunks` to `true`. The ACP demo wrapper exposes the same default, and every composition that omits the field inherits packed writes. `packChunks: false` remains an explicit write-side diagnostic mode that stores one event per line.
Reading is unconditional and layout-blind. Packed, unpacked, and mixed files load into the same contiguous `SessionEvent[]`, so the default does not require a session-format version change or an on-disk runtime migration. The option controls newly appended batches only; it never selects a reader mode.
### Logical events and physical rows
Packing stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is storage vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`.
The JSONL backend packs each durable append batch. Raw `compression: 'none'` and default Zstandard framing carry the same logical storage records; selecting raw mode for reviewable fixtures does not disable packing. Repository replay readers and normalizers decode the shared row format instead of maintaining snapshot-specific codecs.
### Canonical snapshot fixtures
Every committed session-format JSONL fixture uses the canonical packed representation. `scripts/session-fixture-layout.snapshot.ts` discovers tracked `*.jsonl` files and unignored untracked additions repository-wide, selects those whose first record is a `session` header, decodes all body records, and rejects content that differs from `packChunkRuns()` output. The inventory therefore includes ACP, headless, TUI, `apps/web`, parent sessions, child sessions, and future fixture names without a maintained path list.
ACP and headless snapshot runs harvest the default JSONL backend output. TUI and web record-mode writers apply `packChunkRuns()` to their in-memory events before writing fixtures. The authored `packed-chunks` ACP scenario runs under the ordinary config and retains all three packed row kinds; its contract decodes both its independent source fixture and target fixture before asserting event-for-event equality.
Focused package tests keep unpacked and mixed-layout inputs for reader compatibility. They do not opt the default snapshot corpus out of the canonical layout.
### In-flight branch convergence
The temporary [`scripts/migrate-packed-session-fixtures.ts`](../../../../scripts/migrate-packed-session-fixtures.ts) command lets in-flight branches converge after merging current `master`: `pnpm run migrate:packed-session-fixtures` discovers the same repository-wide fixture set as the permanent gate, preserves each header line, decodes existing mixed records, writes the canonical packed body, proves decoded equality, and proves idempotence. It never calls a model or regenerates transcript and presentation outputs.
The command remains linked from the testing policy and ACP snapshot README while older branches may carry fixture edits. The [removal proposal](../../proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) deletes the CLI, package command, this transitional section, and the documentation links once a live open-PR inventory shows that every affected branch is merged, closed, or canonical. The shared canonicalizer and snapshot gate remain permanent.
### Verification contract
JSONL persistence tests prove that omission writes a packed row, explicit `false` writes one event per line, and both forms load identical events. Canonicalizer unit tests cover header preservation, unpacked conversion, non-session JSONL, already-packed idempotence, and malformed input. The keyless snapshot gate covers every committed fixture and assembled replay path; documentation gates keep config defaults and bilingual contracts aligned.
## Alternatives considered
**Flip only the backend schema default.** This leaves wrapper defaults, direct TUI/web serializers, existing fixtures, and future fixture policy inconsistent. A default is meaningful only when shipping compositions and the tests representing them share it.
**Keep snapshots unpacked for readability.** Packed rows retain every fragment and timestamp explicitly, while the shared decoder and normalizer provide logical inspection. Keeping the largest committed consumer on a different layout would make snapshot coverage avoid the shipping write path.
**Remove `packChunks` and always pack.** One writer is simpler, but one-event-per-line output remains useful for diagnostics and for focused mixed-layout compatibility tests. The explicit opt-out preserves those current consumers without weakening the default.
**Batch chunks as logical session events.** This reduces event count, but it delays or reshapes live delivery, renumbers provenance, and requires every UI and replay consumer to understand another streaming unit. Physical packing obtains the storage benefit behind the existing persistence interface.
**Keep the branch migrator permanently.** The read-only canonicalizer and snapshot gate own continuing enforcement. A mutation command has value only while in-flight branches still carry the former fixture layout, so its lifetime is explicitly bounded by the removal proposal.
## Consequences
Ordinary JSONL writes and committed fixtures use fewer physical rows while preserving the exact logical event stream. Runtime readers accept every existing layout, and operators retain a deliberate unpacked diagnostic mode. Raw files are less convenient for per-token line processing, and external tools that incorrectly treat every post-header row as a `SessionEvent` encounter storage tags more often; supported readers call `decodeStorageRecord()`.
The repository carries a large mechanical fixture diff, reviewed through decoded equality and the canonical-layout gate rather than token-by-token line inspection. It also temporarily carries one branch migration command and its links; the separate removal proposal prevents that transition aid from becoming permanent process surface.

View File

@@ -0,0 +1,59 @@
# Agent Note: 将打包分片行设为默认 JSONL 布局
Status: implemented
[English](2026-07-26-packed-chunk-rows-by-default.md) | 中文
## 问题
提供方流会产生大量 token 大小的 `assistant/chunk` 增量事件,其重复 JSON 封装可能比载荷本身更大。会话日志必须将每个分片保留为独立的逻辑事件:实时 `session/event` 传递、序号、`sourceEventSeqs`、回放、取消证据和 UI 流式输出都依赖这些边界。
JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封装开销。一段至少包含 3 个连续、同属一个块的增量事件可以编码为一条 `text-chunks``reasoning-chunks``tool-call-chunks` 存储行,解码则会重建每个原始事件、时间戳和序号。一个可信的默认值必须同时覆盖运行时写入器、应用级配置、快照生成器和签入仓库的 fixture测试前置数据否则测试会绕开部署实际写入的布局。
## 决策
`dsh-session-persistence-jsonl` 会将省略的 `packChunks` 解析为 `true`。ACPAgent Client Protocol演示包装层公开相同的默认值所有省略该字段的组合都会继承打包写入。`packChunks: false` 仍是写入侧显式诊断模式,以每事件一行的形式存储。
读取始终不受选项控制且与布局无关。打包、非打包和混合文件都会加载为相同且连续的 `SessionEvent[]`,因此更改默认值不需要变更会话格式版本,也不需要对磁盘数据执行运行时迁移。该选项只控制新追加的批次,绝不会选择读取器模式。
### 逻辑事件与物理行
打包保留在 `dsh-session` 的存储 seam并通过 `packChunkRuns()``decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于存储词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`
JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none'` 与默认 Zstandard 帧承载相同的逻辑存储记录;为使 fixture 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。
### 规范快照 fixture
每个签入仓库的会话格式 JSONL fixture 都使用规范打包表示。`scripts/session-fixture-layout.snapshot.ts` 会在整个仓库中发现已跟踪的 `*.jsonl` 文件,以及未被忽略的新增未跟踪 JSONL 文件,选择首条记录为 `session` header 的文件,解码所有正文记录,并拒绝与 `packChunkRuns()` 输出不同的内容。因此,该清单无需维护路径列表即可覆盖 ACP、headless、TUI、`apps/web`、父会话、子会话以及未来的 fixture 名称。
ACP 和 headless 快照运行会采集默认 JSONL 后端的输出。TUI 和 web 的记录模式写入器会在写入 fixture 前,对内存事件应用 `packChunkRuns()`。人工编写的 `packed-chunks` ACP 场景在普通配置下运行,并保留全部 3 种打包行类型;其契约先解码独立的源 fixture 和目标 fixture再断言二者逐事件相等。
聚焦的包package测试保留非打包和混合布局输入以验证读取器兼容性。这些测试不会让默认快照语料库豁免规范布局要求。
### 在途分支收敛
临时命令 [`scripts/migrate-packed-session-fixtures.ts`](../../../../scripts/migrate-packed-session-fixtures.ts) 让在途分支合并当前 `master` 后可以完成收敛:`pnpm run migrate:packed-session-fixtures` 会发现与永久门禁相同的仓库级 fixture 集合,保留各文件的 header 行,解码现有混合记录,写入规范打包正文,并证明解码结果相等且操作具有幂等性。该命令绝不会调用模型,也不会重新生成 transcript文本记录与呈现输出。
只要较旧分支仍可能携带 fixture 改动,测试政策和 ACP 快照 README 就会继续链接该命令。最新的开放 PRPull Request清单确认每个受影响分支均已合并、关闭或符合规范后[移除提案](../../proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会删除该 CLI、包命令、本过渡章节和文档链接。共享规范布局转换器与快照门禁保持永久存在。
### 验证契约
JSONL 持久化测试证明:省略选项时会写入打包行,显式传入 `false` 时会按每事件一行的形式写入,两种形式都会加载为完全相同的事件。规范布局转换器单元测试覆盖 header 保留、非打包转换、非会话 JSONL、已打包输入的幂等性和畸形输入。无密钥快照门禁覆盖每个签入仓库的 fixture 和组装后的回放路径;文档门禁则确保配置默认值与双语契约保持一致。
## 曾考虑的替代方案
**仅翻转后端 schema 默认值。** 这会让包装层默认值、TUI/web 直接序列化器、现有 fixture 与未来 fixture 政策仍然彼此不一致。只有已交付组合及代表这些组合的测试采用相同默认值时,该默认值才有意义。
**快照继续使用非打包格式以便阅读。** 打包行仍会显式保留每个片段和时间戳,共享解码器与规范化器则提供逻辑检查。如果让规模最大的签入仓库消费方采用不同布局,快照覆盖就会绕开已交付的写入路径。
**删除 `packChunks` 并始终打包。** 只保留一个写入器更简单,但每事件一行的输出仍适用于诊断和聚焦的混合布局兼容性测试。显式停用选项在不削弱默认值的同时,保留了这些现有消费方。
**把分片批量合并为逻辑会话事件。** 这会减少事件数量,但也会延迟或重塑实时传递,改变溯源信息所引用的序号,并要求每个 UI 和回放消费方理解另一种流式单位。物理打包通过现有持久化接口获得存储收益。
**永久保留分支迁移器。** 只读的规范布局转换器与快照门禁负责持续强制执行。只有在途分支仍携带旧 fixture 布局时,会修改仓库内容的命令才有价值,因此移除提案明确限定了其生命周期。
## 后果
常规 JSONL 写入与签入仓库的 fixture 使用更少的物理行,同时精确保留逻辑事件流。运行时读取器接受所有现有布局,操作方也保留有意提供的非打包诊断模式。按 token 逐行处理原始文件较为不便;错误地将 header 后每一行都视为 `SessionEvent` 的外部工具会更频繁地遇到存储 tag受支持的读取器则会调用 `decodeStorageRecord()`
仓库会产生大规模机械 fixture diff评审应依据解码结果相等这一事实和规范布局门禁而不是逐行、逐 token 检查。仓库还会暂时保留一个分支迁移命令及其链接;单独的移除提案会防止这项过渡辅助机制成为永久的流程接口。

View File

@@ -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
2026-06-19-acp-snapshot-tests.md: b4cda8f32fe7a84a977bcbdbe5db0671cb9a7083
2026-06-19-acp-snapshot-tests.zh.md: 5337c3852b524af4e8c556e93ec80084b30a6d0b
2026-06-19-acp-snapshot-tests.md: 6e9f07cd65069423a61f94af44713054470395c6
2026-06-19-acp-snapshot-tests.zh.md: 95c2ef6dd55d70202a9985c824c508c2dda42c00

View File

@@ -20,7 +20,7 @@ A snapshot test boots the real ACP example, drives its stdio protocol from a det
Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary session artifact therefore serves as both replay source and behavioral expected output.
When a scenario pins an alternative physical storage layout, its fixture is mechanically derived from a real unpacked counterpart. The scenario test requires every intended storage-row kind and exact event-for-event equality after decoding before the ordinary replay and log comparison proves that the assembled process consumes and reproduces that layout.
Every committed session-format fixture uses the canonical packed physical layout. The all-row-kinds scenario is mechanically derived from an independent real recording; its test requires every packed storage-row kind and exact event-for-event equality after both fixtures decode, then ordinary replay and log comparison prove that the assembled process consumes and reproduces the layout.
### Replay derives the model script from the log
@@ -44,7 +44,7 @@ Replay is positional and therefore permits only one in-flight model stream per s
### Recording harvests the log; keyless replay needs a providerless config
Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend configured with `persistenceCompression: 'none'`, then copies the produced `.jsonl` into the scenario dir. The explicit raw mode keeps committed replay fixtures line-readable while ordinary deployments use the backend's compressed default. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only.
Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend configured with `persistenceCompression: 'none'`, then copies the produced `.jsonl` into the scenario dir. The explicit raw mode keeps committed replay fixtures line-readable while ordinary deployments use the backend's compressed default; eligible chunk runs still use the default packed storage rows. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only.
Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with `llm-replay` while retaining the live composition. Recording uses the ordinary config and a harness-supplied persistence root. Replay mode skips `.env` loading, so a stray API key cannot trigger a live call. See the [single-source config Agent Note](2026-07-04-single-source-acp-replay-config.md).
@@ -69,7 +69,7 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log
### Two subcommands, replay in the default gate
`pnpm run test:snapshot` replays committed fixtures keylessly; `test:snapshot:record` uses the real API and rewrites the harvested session log and stdout expected output. Missing fixtures fail loud. Every scenario carries `input.json`, `stdout.expected.jsonl`, and `session.jsonl`; no-model cases use a header-only log. `replay.override.json` is required only for scenarios marked `overridden`, because its presence replaces derived replay. Fixture guards reject missing, mismatched, and orphaned files. Both commands accept scenario filters.
`pnpm run test:snapshot` replays committed fixtures keylessly; `test:snapshot:record` uses the real API and rewrites the harvested session log and stdout expected output. The same keyless gate discovers repository JSONL by its `session` header and rejects any fixture that differs from the shared codec's canonical packed representation. Missing fixtures fail loud. Every scenario carries `input.json`, `stdout.expected.jsonl`, and `session.jsonl`; no-model cases use a header-only log. `replay.override.json` is required only for scenarios marked `overridden`, because its presence replaces derived replay. Fixture guards reject missing, mismatched, and orphaned files. Both commands accept scenario filters.
## Alternatives considered

View File

@@ -20,7 +20,7 @@ Status: implemented
每个场景的 `session.jsonl` 都从真实运行中采集。`assistant/chunk` 事件复现模型流;工具、消息和边界事件捕获 harness 行为。因此,一份普通会话产物同时充当重放来源和行为预期输出。
当场景固定另一种物理存储布局时,其 fixture 会从真实的未打包对应项机械派生。场景测试要求包含每一种预期存储行类型,并在解码后逐事件精确相等;随后,普通重放与日志比较会证明组后的进程能够消费并复现该布局。
每个签入仓库的会话格式 fixture 都使用规范的打包物理布局。覆盖所有行类型的场景从一份独立的真实录制机械派生测试要求包含每一种打包存储行类型,并在两份 fixture 解码后逐事件精确相等;随后,普通重放与日志比较会证明组后的进程能够消费并复现该布局。
### 回放从日志推导模型脚本
@@ -44,7 +44,7 @@ Status: implemented
### 录制采集日志;无密钥回放需要无提供方的配置
记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。
记录模式使用真实 `llm-deepseek` 适配器和配置为 `persistenceCompression: 'none'` 的 JSONL 持久化后端运行场景,再把生成的 `.jsonl` 复制到场景目录。显式 raw 模式让已提交重放 fixture 保持逐行可读,而普通部署使用后端的压缩默认值;符合条件的分片连续段仍使用默认的打包存储行。逐事件追加具有持久性,但 harness 会在采集前优雅关闭子进程(关闭 stdin → `await ctx.dispose()`),以确保最终事件已刷出。`llm-replay` 本身不执行记录——它只负责重放。
重放使用 `cordis.snapshot.yml` overlay`llm-replay` 替换真实适配器,同时保留实时组合。记录使用普通配置和由 harness 提供的持久化根目录。重放模式跳过 `.env` 加载,因此意外存在的 API 密钥不会触发实时调用。参见[单一来源配置 Agent Note](2026-07-04-single-source-acp-replay-config.md)。
@@ -69,7 +69,7 @@ Status: implemented
### 两个子命令,回放在默认门禁中
`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture`test:snapshot:record` 使用真实 API并重写采集的会话日志与 stdout 预期输出。缺少 fixture 时会响亮失败。每个场景都包含 `input.json``stdout.expected.jsonl``session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。
`pnpm run test:snapshot` 无需密钥即可重放已提交 fixture`test:snapshot:record` 使用真实 API并重写采集的会话日志与 stdout 预期输出。同一无密钥门禁会通过 `session` header 发现仓库中的 JSONL并拒绝与共享编解码器的规范打包表示不同的任何 fixture。缺少 fixture 时会响亮失败。每个场景都包含 `input.json``stdout.expected.jsonl``session.jsonl`;不调用模型的情况使用仅有请求头的日志。只有标记为 `overridden` 的场景才需要 `replay.override.json`因为它一旦存在就会取代派生重放。Fixture 守卫会拒绝缺失、不匹配和孤立文件。两个命令都接受场景过滤器。
## 曾考虑的替代方案

View File

@@ -1,56 +0,0 @@
# Agent Note: Make packed chunk rows the default JSONL layout
Status: proposed
English | [中文](2026-07-26-packed-chunk-rows-by-default.zh.md)
## Problem
The JSONL persistence backend can losslessly replace a run of at least three consecutive same-block `assistant/chunk` delta events with one `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` storage row. Loading expands that row back into the exact events, including sequence numbers, timestamps, and chunk boundaries. The codec therefore reduces repeated JSON envelopes without changing the authoritative logical session log.
`packChunks` nevertheless defaults to `false` in both `dsh-session-persistence-jsonl` and the ACP demo composition. That default was chosen so the first packed-row implementation could land without rewriting the snapshot corpus. It now makes the ordinary write path, most tests, and almost every committed session fixture exercise the larger one-event-per-line representation, while only one dedicated ACP scenario exercises packing.
The snapshot corpus is part of the default contract, not disposable test data. ACP and headless snapshots harvest physical persistence files, but the TUI snapshot writer serializes `Session.events` directly and bypasses the backend encoder. Flipping one schema default would therefore leave different products and test tiers with different physical layouts, and future fixtures could silently return to unpacked rows.
This proposal changes only the physical storage representation. Every provider chunk remains one logical `assistant/chunk` session event, is delivered live through `session/event`, occupies its own sequence number, and remains addressable by `sourceEventSeqs` after load. Coalescing live events before `Session.append()` is outside this proposal because it would change UI streaming, cancellation evidence, provenance, and replay semantics established by the [session-persistence decision](../../implemented/architecture/2026-06-14-session-persistence.md).
## Proposal
Packed chunk rows become the default physical layout for every JSONL writer, shipping composition, default-path test, and committed session-log fixture. The JSONL backend resolves omitted `packChunks` to `true`; the ACP demo's pass-through config does the same; CLI, TUI, headless, and other compositions that omit the option inherit the backend default.
`packChunks: false` remains an explicit write-side opt-out for line-per-event diagnostics and compatibility tests. Reading stays unconditional and layout-blind, so packed, unpacked, and mixed existing logs continue to load without migration or a session-format version change. The option controls only newly appended batches; it does not select a reader mode.
The packed codec remains at the `dsh-session` storage seam. Persistence, fixture producers, normalizers, and replay readers share `packChunkRuns()` and `decodeStorageRecord()` rather than introducing a snapshot-only encoding. Packing remains per durable append batch and retains the existing minimum run length and exact-shape allowlist.
## Implementation plan
1. Change `SessionPersistenceJsonl.Config.packChunks` and the ACP demo wrapper default to `true`. Update their JSDoc, bilingual READMEs, generated config catalog, and every current-state statement that calls packed rows opt-in. Keep the explicit boolean so deployments can request unpacked writes without coupling that choice to `compression: 'none'`.
2. Make the JSONL backend's default-path tests assert packed output without passing `packChunks: true`. Retain narrowly named tests for `packChunks: false`, byte-identical unpacked writes, mixed-layout reads, malformed packed rows, and torn tails. Tests whose subject is unrelated persistence behavior omit the flag and therefore exercise the shipping default.
3. Make every snapshot fixture producer emit the same physical layout. ACP and headless suites harvest the backend's packed raw-mode artifacts. The TUI snapshot writer applies the shared codec instead of mapping `session.events` directly to lines. Raw `compression: 'none'` remains necessary for reviewable fixtures but no longer implies one logical event per physical line.
4. Re-encode every committed session-format JSONL fixture by decoding its current records and packing the recovered event list after the unchanged header. This includes parent and child `session*.jsonl` files plus replay and expected-session files whose first record is `session`. The migration must prove exact decoded event equality before and after; it does not call a model or regenerate transcript content.
5. Remove the `packed-chunks.cordis.yml` and replay overlay because packing no longer needs a special composition. Keep the authored `packed-chunks` scenario as the all-row-kinds contract under the ordinary config: it must contain `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`, decode event-for-event equal to its independent source fixture, and re-persist identically through the assembled application.
6. Add an inventory-free check to the keyless snapshot gate that discovers session-format JSONL fixtures by their `session` header, decodes them, and rejects any fixture whose physical records differ from the canonical packed encoding. This covers future scenarios and child logs without a hand-maintained path list. Explicit unpacked and mixed-layout compatibility inputs stay in focused package tests, not the default snapshot corpus.
7. Update the implemented session-persistence and snapshot Agent Notes to distinguish logical events from storage records and to describe packed fixtures as the ordinary layout. Run focused codec and JSONL persistence coverage, every snapshot suite, documentation synchronization, lint, and whitespace validation.
## Alternatives considered
**Flip only the backend schema default.** This would change most runtime writes but leave the ACP wrapper's resolved default, TUI's direct serializer, existing fixtures, and future fixture policy inconsistent. A default is credible only when shipping compositions and the tests that represent them share it.
**Keep snapshots unpacked for readability.** The decoder and normalizer already understand packed rows, and one row retains every chunk boundary and timestamp explicitly. Keeping the largest committed consumer on the legacy layout would make snapshot coverage avoid the shipping write path and preserve the original reason the default stayed off.
**Remove `packChunks` and always pack.** One canonical writer is simpler, but an explicit unpacked form remains useful for line-oriented diagnostics and for proving mixed-layout compatibility. The pre-release stance permits removing the option later if those concrete uses disappear; changing the default does not require that additional decision.
**Batch chunks as logical session events.** This would reduce event count rather than only storage envelopes, but it would also delay or reshape live `session/event` delivery, renumber provenance, and require every UI and replay consumer to understand a second streaming unit. The storage codec already obtains the size benefit behind a smaller interface without changing those contracts.
## Acceptance criteria
- Omitting `packChunks` writes eligible runs as packed rows in the JSONL backend and every shipping app composition.
- `packChunks: false` still writes one event per line, while both configurations read packed, unpacked, and mixed logs into identical contiguous `SessionEvent[]` values.
- Every committed session-format snapshot fixture is in canonical packed form, and a keyless top-level snapshot check prevents unpacked packable runs from returning.
- ACP, headless, and TUI snapshot recording or refresh preserves the packed layout without changing the decoded event stream, model script, transcript, or expected user output.
- The ordinary packed scenario retains all three row kinds and exact decoded equality with its source fixture without a packing-specific config overlay.
- Current documentation consistently calls packed rows the default physical JSONL layout and preserves the distinction between storage rows and logical `assistant/chunk` events.
## Risks
The implementation creates a large fixture diff even though logical behavior is unchanged; reviewers must use decoded equality and the canonical-layout check rather than inspect thousands of mechanical line replacements. Tools that read raw JSONL and assume every post-header line is a `SessionEvent` will encounter storage-row tags more often, although that assumption is already outside the documented format and the repository readers decode rows unconditionally. Packed rows also make a raw file less convenient for per-token line processing; `packChunks: false` remains the deliberate escape hatch.

View File

@@ -1,56 +0,0 @@
# Agent Note: 将打包分片行设为默认 JSONL 布局
Status: proposed
[English](2026-07-26-packed-chunk-rows-by-default.md) | 中文
## 问题
JSONL 持久化后端可将一段至少包含 3 个连续、同属一个块的 `assistant/chunk` 增量事件,无损替换为一条 `text-chunks``reasoning-chunks``tool-call-chunks` 存储行。加载时,后端会将该存储行展开为完全一致的事件,包括序列号、时间戳和分片边界。因此,该编解码器可减少重复的 JSON 封装,而不会改变作为权威依据的逻辑会话日志。
然而,`packChunks` 仍默认为 `false``dsh-session-persistence-jsonl` 和 ACPAgent Client Protocol演示组合都是如此。选择这一默认值是为了让首个打包行实现在不重写快照语料库的情况下合入。目前常规写入路径、大多数测试以及几乎所有签入仓库的会话 fixture测试前置数据都会使用体积更大的每事件一行表示只有一个专用 ACP 场景覆盖打包行为。
快照语料库属于默认契约而非可随意丢弃的测试数据。ACP 和 headless 快照采集物理持久化文件,但 TUI 快照写入器会直接序列化 `Session.events`,绕过后端编码器。因此,仅翻转一个 schema 默认值,会让不同产品和测试层级采用不同的物理布局,后续 fixture 也可能在无人察觉的情况下退回非打包行。
本提案仅改变物理存储表示。每个提供方分片仍是一个逻辑 `assistant/chunk` 会话事件,经 `session/event` 实时传递,各自占用一个序列号,并在加载后仍可由 `sourceEventSeqs` 寻址。在 `Session.append()` 之前合并实时事件不在本提案范围内,因为这会改变 UI 流式输出、取消证据、溯源信息以及[会话持久化决策](../../implemented/architecture/2026-06-14-session-persistence.md)确立的回放语义。
## 提案
打包分片行成为所有 JSONL 写入器、已交付组合、默认路径测试和签入仓库的会话日志 fixture 所采用的默认物理布局。省略 `packChunks`JSONL 后端将其解析为 `true`ACP 演示的透传配置同样如此CLI命令行界面、TUI、headless 及其他省略该选项的组合会继承后端默认值。
`packChunks: false` 继续作为写入侧显式停用选项,用于每事件一行的诊断和兼容性测试。读取仍不受该选项控制且与布局无关,因此现有的打包、非打包和混合日志无需迁移或更改会话格式版本,仍可继续加载。该选项只控制新追加的批次,不会选择读取器模式。
打包编解码器仍位于 `dsh-session` 的存储 seam。持久化、fixture 生成器、规范化器和回放读取器共享 `packChunkRuns()``decodeStorageRecord()`,而不引入仅供快照使用的编码。打包仍以每个持久追加批次为单位,并保留现有的最小连续段长度和精确形态允许列表。
## 实施计划
1.`SessionPersistenceJsonl.Config.packChunks` 和 ACP 演示包装层的默认值改为 `true`。更新其 JSDoc、双语 README、生成的配置目录以及每处将打包行称为可选启用项的现状说明。保留显式布尔值使部署可以请求非打包写入而无需将这一选择与 `compression: 'none'` 绑定。
2. 让 JSONL 后端的默认路径测试在不传入 `packChunks: true` 的情况下断言打包输出。保留名称明确且范围聚焦的测试,以覆盖 `packChunks: false`、逐字节相同的非打包写入、混合布局读取、畸形打包行和撕裂尾部。主题与打包无关、关注其他持久化行为的测试省略该标志,从而覆盖实际交付的默认值。
3. 让每个快照 fixture 生成器都输出相同的物理布局。ACP 和 headless 套件采集后端在原始模式下生成的打包产物。TUI 快照写入器改用共享编解码器,不再直接将 `session.events` 映射为行。为了让 fixture 便于评审,仍需使用原始模式 `compression: 'none'`,但这不再意味着每个逻辑事件对应一条物理行。
4. 重新编码每个签入仓库的会话格式 JSONL fixture先解码其当前记录再在保持 header 不变的前提下打包还原出的事件列表。范围包括父级和子级 `session*.jsonl` 文件,以及首条记录为 `session` 的回放文件和预期会话文件。迁移必须证明前后解码出的事件完全相等;它不会调用模型,也不会重新生成 transcript文本记录内容。
5. 移除 `packed-chunks.cordis.yml` 及其回放 overlay因为打包不再需要专用组合。保留人工编写的 `packed-chunks` 场景,在普通配置下继续作为覆盖所有行种类的契约:它必须包含 `text-chunks``reasoning-chunks``tool-call-chunks`,解码出的事件必须与其独立源 fixture 逐事件相等,并且通过组装后的应用重新持久化时保持完全一致。
6. 在无密钥快照门禁中增加一项无需清单的检查:通过 `session` header 发现会话格式 JSONL fixture解码后拒绝物理记录与规范打包编码不同的任何 fixture。这样无需手工维护路径列表即可覆盖未来场景和子级日志。显式的非打包与混合布局兼容性输入仍保留在聚焦的包package级测试中不进入默认快照语料库。
7. 更新已实现的会话持久化与快照 Agent Noteagent 决策记录),区分逻辑事件与存储记录,并说明打包 fixture 是常规布局。运行聚焦的编解码器与 JSONL 持久化覆盖率、全部快照套件、文档同步、lint 和空白校验。
## 备选方案
**仅翻转后端 schema 默认值。** 这会改变大多数运行时写入,但 ACP 包装层解析后的默认值、TUI 的直接序列化器、现有 fixture 和未来 fixture 政策仍会彼此不一致。只有已交付组合及代表这些组合的测试采用相同默认值时,该默认值才可信。
**快照继续使用非打包格式以便阅读。** 解码器和规范化器已经能够理解打包行,而且一条存储行仍会显式保留每个分片边界与时间戳。如果让规模最大的已签入消费方继续使用旧布局,快照覆盖就会绕开已交付的写入路径,也会保留当初未启用该默认值的原因。
**删除 `packChunks` 并始终打包。** 只保留一个规范写入器更简单,但显式的非打包形式仍适用于面向行的诊断,也可用于证明混合布局兼容性。预发布立场允许在这些具体用途消失后移除该选项;更改默认值不要求同时作出这一额外决策。
**把分片批量合并为逻辑会话事件。** 这会减少事件数量,但也会延迟或重塑 `session/event` 的实时传递,改变溯源信息所引用的序号,并要求每个 UI 和回放消费方理解第二种流式单位。存储编解码器已经通过更窄的接口获得体积收益,无需改变这些契约。
## 验收标准
- 省略 `packChunks`JSONL 后端和每个已交付应用组合都会将符合条件的连续段写为打包行。
- `packChunks: false` 仍会按每事件一行的形式写入;无论采用哪种配置,读取打包、非打包和混合日志时,都会得到完全相同且连续的 `SessionEvent[]` 值。
- 每个签入仓库的会话格式快照 fixture 都采用规范打包形式;一项无密钥顶层快照检查会防止可打包的非打包连续段再次出现。
- ACP、headless 和 TUI 的快照录制或刷新会保留打包布局而不会改变解码后的事件流、模型脚本、transcript 或预期用户输出。
- 普通配置下的打包场景保留全部 3 种行,并在没有打包专用配置 overlay 的情况下,与其源 fixture 保持精确的解码事件相等性。
- 当前文档统一将打包行称为默认物理 JSONL 布局,并保留存储行与逻辑 `assistant/chunk` 事件之间的区别。
## 风险
尽管逻辑行为不变,实现仍会产生大规模 fixture diff评审人必须依据解码后的相等性和规范布局检查进行评审而不是检查数千处机械行替换。读取原始 JSONL 并假定 header 后每一行都是 `SessionEvent` 的工具,会更频繁地遇到带存储行 tag 的记录;不过,这一假设本就不属于成文格式契约,仓库中的读取器也始终无条件解码记录。打包行还会降低原始文件按 token 逐行处理的便利性;`packChunks: false` 是有意保留的退路。

View File

@@ -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
2026-07-26-remove-packed-session-fixture-migrator.md: d5f8ff65a38618c5f321f096921f7ce2b8af2d75
2026-07-26-remove-packed-session-fixture-migrator.zh.md: d46e9e035709c26f59cb7f0a6908e38d0da08bbe

View File

@@ -0,0 +1,38 @@
# Agent Note: Remove the packed-session fixture branch migrator
Status: proposed
English | [中文](2026-07-26-remove-packed-session-fixture-migrator.zh.md)
## Problem
The repository's default writers and snapshot check keep session fixtures in the canonical packed-row layout. `pnpm run migrate:packed-session-fixtures` remains alongside that permanent enforcement only so in-flight branches carrying older fixture edits can merge current `master` and mechanically converge without re-recording model output.
Once every such branch is merged, closed, or already canonical, the write command and its branch-convergence instructions have no continuing owner. Keeping a mutation command after its transition ends adds a second apparent maintenance path beside the permanent read-only snapshot check.
## Proposal
Remove the temporary `scripts/migrate-packed-session-fixtures.ts` CLI and the root `migrate:packed-session-fixtures` package command after a live inventory confirms that no open pull request still needs to convert session-format JSONL. Remove the transitional command links from the testing policy, the ACP snapshot README, and the implemented packed-row Agent Note in the same change.
Retain `scripts/session-fixture-layout.ts`, its unit tests, and `scripts/session-fixture-layout.snapshot.ts`. They define and enforce the permanent canonical layout; only the branch-facing writer is temporary.
Before removing the command, each affected branch merges the current `master`, runs the migrator once, commits the resulting fixture-only rewrite separately, and verifies that the repository-wide snapshot layout check passes. Closed or superseded branches require no migration.
## Alternatives considered
**Keep the command indefinitely.** This makes old fixture conversion convenient, but it leaves a repository-wide mutation tool after the only known migration window closes. The read-only gate already supplies the durable behavior and diagnostic.
**Remove the canonicalization module with the CLI.** The module is not transition residue: snapshot CI uses it to discover future fixtures, decode mixed physical records, and compare them with the canonical packed representation. Removing it would also remove enforcement.
**Delete the command immediately when packed rows reach `master`.** Older open branches would then need ad hoc scripts or manual snapshot regeneration after retargeting, increasing conflict risk and making decoded-event preservation harder to review.
## Acceptance criteria
- A live open-PR inventory finds no branch with session-format JSONL changes that still depends on the temporary migration command.
- The temporary CLI, root package command, and every branch-convergence link are absent; the permanent canonicalizer, unit tests, and snapshot check remain.
- `pnpm run test:snapshot`, `pnpm run doc-sync`, lint, and whitespace validation pass without the temporary command.
- Current documentation describes only the packed default and permanent canonical-layout enforcement.
## Risks
An incomplete open-branch inventory could strand a contributor with a large unpacked fixture conflict after the command disappears. The removal therefore depends on live pull-request evidence, not elapsed time. Retaining the command too long has a smaller operational cost but obscures which mechanism is permanent.

View File

@@ -0,0 +1,38 @@
# Agent Note: 移除打包会话 fixture 分支迁移器
Status: proposed
[English](2026-07-26-remove-packed-session-fixture-migrator.md) | 中文
## 问题
仓库的默认写入器和快照检查会使会话 fixture测试前置数据保持规范打包行布局。在永久强制机制之外仍保留 `pnpm run migrate:packed-session-fixtures`,唯一原因是让携带旧版 fixture 改动的在途分支可以合并当前 `master`,并在不重新录制模型输出的情况下通过机械转换收敛。
一旦每个此类分支均已合并、关闭或符合规范,写入命令及其分支收敛指引便不再有持续维护者。过渡结束后继续保留会修改仓库内容的命令,会在永久只读快照检查旁增加第二条看似有效的维护路径。
## 提案
最新清单确认不再有任何开放 PRPull Request需要转换会话格式 JSONL 后,移除临时 CLI `scripts/migrate-packed-session-fixtures.ts`以及根包package提供的 `migrate:packed-session-fixtures` 命令。在同一变更中移除测试政策、ACP 快照 README 和已实现打包行 Agent Noteagent 决策记录)中指向该过渡命令的链接。
保留 `scripts/session-fixture-layout.ts`、其单元测试和 `scripts/session-fixture-layout.snapshot.ts`。它们定义并强制执行永久规范布局;只有面向分支的写入器是临时机制。
移除命令前,每个受影响分支都要合并当前 `master`,运行一次迁移器,单独提交由此产生的仅 fixture 重写,并验证仓库级快照布局检查通过。已关闭或被取代的分支无需迁移。
## 曾考虑的替代方案
**无限期保留该命令。** 这会让旧 fixture 转换更方便,但也会在唯一已知迁移窗口关闭后,留下一个仓库级写入工具。只读门禁已经提供可长期保留的行为与诊断。
**随 CLI 一同移除规范布局转换模块。** 该模块不是过渡残留:快照 CI 使用它发现未来 fixture、解码混合物理记录并与规范打包表示进行比较。移除该模块也会移除强制机制。
**打包行进入 `master` 后立即删除命令。** 较旧的开放分支在重新定向后,只能使用临时脚本或手动重新生成快照,这会增加冲突风险,也会让解码事件保真度更难评审。
## 验收标准
- 最新开放 PR 清单未发现任何仍依赖临时迁移命令处理会话格式 JSONL 改动的分支。
- 临时 CLI、根包命令与所有分支收敛链接均不存在永久规范布局转换器、单元测试和快照检查仍然保留。
- `pnpm run test:snapshot``pnpm run doc-sync`、lint 和空白校验在没有临时命令的情况下通过。
- 当前文档仅描述打包默认值和永久规范布局强制机制。
## 风险
若开放分支清单不完整,命令消失后,贡献者可能会受困于大规模非打包 fixture 冲突。因此,移除操作取决于实时 PR 证据,而不是经过的时间。保留命令过久的运维成本较低,但会模糊哪一种机制才是永久机制。

View File

@@ -31,8 +31,14 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot'
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionStore, {
packChunkRuns,
SESSION_FORMAT_VERSION,
SessionId,
type Session,
type SessionEvent,
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -263,14 +269,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
/**
* Serialize a live session back to raw session-JSONL (header + events) — the
* Serialize a live session to the canonical raw session-JSONL layout — the
* in-memory record-mode harvest, so the on-disk zstd default never matters.
* Mirrors the TUI suite's rawSessionLog.
*/
function rawSessionLog(session: Session): string {
return [
JSON.stringify({ type: 'session', ...session.header }),
...session.events.map(event => JSON.stringify(event)),
...packChunkRuns(session.events).map(record => JSON.stringify(record)),
'',
].join('\n')
}

View File

@@ -59,7 +59,7 @@ export interface Config {
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `true`. */
packChunks?: boolean
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
@@ -990,10 +990,9 @@ export interface Config {
/**
* Write runs of consecutive `assistant/chunk` delta events as packed
* `text-chunks`/`reasoning-chunks`/`tool-call-chunks` rows (lossless,
* ~60% smaller logs measured on a real session). Off by default while
* snapshot fixtures stay in the one-event-per-line layout: recording with
* packing on rewrites every golden `session.jsonl`. READING packed rows is
* unconditional — a log's layout never depends on this switch.
* ~60% smaller logs measured on a real session). Defaults to true; false
* keeps one `SessionEvent` per line for diagnostics. Reading packed rows is
* unconditional: a log's layout never depends on this switch.
*/
packChunks?: boolean
/** Physical encoding; defaults to checksummed Zstandard frames. */

View File

@@ -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
session.md: d789ffcabb5cb0c744e265b61e322831c1d8a04f
session.zh.md: f4f102861db7403520e9f38cb56613e430718cbe
session.md: 2cbbac8042d04522fea0b1ed7a66c503e4b63f4e
session.zh.md: e932c8f99f684f1b8985b006ab4ddb145db966bd

View File

@@ -560,6 +560,6 @@ The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepse
## Durability contract
What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's opt-in packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format.
What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format.
The backends that consume this contract are on [persistence.md](persistence.md).

View File

@@ -564,6 +564,6 @@ interface TurnEndReasonMap {
## 持久性契约
持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk``seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可JSONL 后端可选启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。
持久化后端依赖的契约如下:持久日志无损保存每个事件,**包括** `assistant/chunk``seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.md))。所有 `event.data` 都必须可序列化为 JSON`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增携带不可序列化数据的事件类型,或破坏会话不变式配套插件所检查的轮次/步骤嵌套,会构成磁盘格式的破坏性变更。
消费此契约的后端见 [persistence.md](persistence.md)。

View File

@@ -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
testing.md: 678d2e218590f70e6424a60286e46db87cf278cc
testing.zh.md: 776f09bfe534f8460efda59623dc8f139cd43fe7
testing.md: 3397c911aacf2db1050a5bcb5be53c0f63a4ddd0
testing.zh.md: 5703acbf6a9962932d9842b2d39fbc799277fb92

View File

@@ -12,6 +12,8 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **Web browser snapshot** (gate-exempt `pnpm run test:web`): real chromium over the in-process web composition replays recorded fixtures against conversation aria goldens (`apps/web/tests/snapshots/`); record/refresh semantics and the deferred CI browser decision: [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge.
## The with-key policy: inference is cheap here
We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Cover file-writing prompts, multi-turn conversations, tool use, and mid-stream cancellation. Highest-value are **smoke tests** that boot the real example, send one prompt, and check the world — they catch the "green unit tests, broken product" class that mocks cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). Self-skip keeps secretless CI and keyless contributors unblocked; it is not a cost signal. Every example ships keyless and with-key smokes ([examples/AGENTS.md](../examples/AGENTS.md)).

View File

@@ -12,6 +12,8 @@
- **快照**`pnpm run test:snapshot`无密钥预期输出覆盖对外行为传输契约与呈现持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff[ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)headless 通过真实单次运行进程固定 `stream-json`。TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript文本记录发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture测试前置数据将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **Web 浏览器快照**(豁免门禁的 `pnpm run test:web`):真实 chromium 在进程内 web 组装之上回放已录制 fixture与会话区 aria 预期输出比对(`apps/web/tests/snapshots/``DSH_SNAPSHOT=record`/`refresh` 的语义与暂缓的 CI 浏览器决策见 [web e2e 车道 Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)。
签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。
## 带密钥策略:推理在这里很便宜
我们是 DeepSeek不要吝惜真实 API 测试。无密钥测试只能证明底层通路;只有带密钥运行才能证明 agent智能体能对接真实模型正常工作。覆盖文件写入提示词、包含多个轮次的对话、工具使用和流中取消。价值最高的是**冒烟测试**:启动真实示例、发送一条提示词,并检查外部世界;它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题([事故复盘 0001](postmortem/0001-acp-default-export-drops-inject.md))。自动跳过让无密钥 CI 和无密钥贡献者不受阻塞;它不是成本信号。每个示例都提供无密钥和带密钥冒烟测试([examples/AGENTS.md](../examples/AGENTS.md))。

View File

@@ -1,45 +0,0 @@
# Keyless replay counterpart of packed-chunks.cordis.yml. Patches do not
# compose across includes, so this applies the packChunks config and the
# DeepSeek-to-replay swap directly to `cordis.yml`.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
config:
runnerCommand:
- bash
- -c
- while [ "$1" != "--" ]; do shift; done; shift; exec "$@"
- passthrough-runner
runnerFailureSignatures:
- 'passthrough-runner: profile rejected'
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: 'none'
packChunks: true
workspaceContext:
maxBytes: 65536
persona: |
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek
name: DeepSeek
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro

View File

@@ -1,23 +0,0 @@
# The packed-chunk-rows overlay: the base tree with the JSONL backend's
# `packChunks` switched on, so delta-chunk runs persist as packed storage rows.
# A config patch replaces the whole app config, so unchanged base fields are
# restated below.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
packChunks: true
workspaceContext:
maxBytes: 65536
persona: |
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.

View File

@@ -37,7 +37,6 @@ const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url))
const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url))
const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url))
const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url))
const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
@@ -76,10 +75,10 @@ const SCENARIOS: Scenario[] = [
// Its prompt and tool-schema sidecars pin the composed header.
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
// Authored from the real PACKED_CHUNKS_SOURCE recording under the same app
// composition. The contract below pins decoded equality and all three row
// kinds; replay additionally proves the assembled app re-packs identically.
{ name: 'packed-chunks', hasModelTurn: true, recorded: false, configPath: PACKED_CHUNKS_CONFIG },
// Authored from the real PACKED_CHUNKS_SOURCE recording under the ordinary
// app composition. The contract below pins decoded equality and all three
// row kinds; replay additionally proves the assembled app re-packs identically.
{ name: 'packed-chunks', hasModelTurn: true, recorded: false },
// The fs overlay only adds the spill stack (the sandboxed filesystem tools
// live in the base tree), so these scenarios share the default header class.
{
@@ -282,5 +281,9 @@ it('packed ACP fixture retains every chunk row kind without changing the logical
})
expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks'])
expect([packed[0], ...packed.slice(1).flatMap(record => decodeStorageRecord(record))]).toStrictEqual(source)
const logicalRecords = (records: readonly unknown[]): unknown[] => [
records[0],
...records.slice(1).flatMap(record => decodeStorageRecord(record)),
]
expect(logicalRecords(packed)).toStrictEqual(logicalRecords(source))
})

View File

@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
@@ -66,7 +66,7 @@ async function seedResumeSession(cwd: string): Promise<void> {
await mkdir(dirname(file), { recursive: true })
await writeFile(file, [
JSON.stringify(toHeaderLine(meta)),
...events.map(event => JSON.stringify(event)),
...packChunkRuns(events).map(record => JSON.stringify(record)),
'',
].join('\n'))
}

View File

@@ -17,8 +17,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { packChunkRuns, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
@@ -154,7 +153,7 @@ function userPrompts(rawLog: string): string[] {
function rawSessionLog(session: Session): string {
return [
JSON.stringify({ type: 'session', ...session.header }),
...session.events.map(event => JSON.stringify(event)),
...packChunkRuns(session.events).map(record => JSON.stringify(record)),
'',
].join('\n')
}

View File

@@ -27,6 +27,7 @@
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
"test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
"migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts",
"test:web": "npm run build:web && vitest run --config vitest.web.config.ts",
"test:gui": "vitest run packages/client packages/host",
"check:all": "tsx scripts/run-gates.ts check-all",

View File

@@ -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
README.md: 18d6d385ff0c35ddbe7dc9a172ce9cd563bc4c1c
README.zh.md: 93ea574eb01fd27fcd68f8b58a9e4187dfbd4fcb
README.md: e46ff43c95df0ae1a6ec536d30417b342c11b151
README.zh.md: abe4dbef6c7d26861cab987704c772a45e57a808

View File

@@ -52,7 +52,7 @@ Durable values need one accepted representation, not a check followed by a secon
### Chunk-row storage codec (`chunk-rows.ts`)
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the write-side switch is the backend's `packChunks` config.
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the backend's default-enabled `packChunks` config controls writes only.
### Surface types

View File

@@ -52,7 +52,7 @@
### 分片行存储编解码器(`chunk-rows.ts`
提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks``reasoning-chunks``tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0``time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq``time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture测试前置数据读取器`dsh-llm-replay``dsh-acp-snapshot`)共享同一编解码器;写入侧开关是后端的 `packChunks` 配置。
提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks``reasoning-chunks``tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0``time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq``time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture测试前置数据读取器`dsh-llm-replay``dsh-acp-snapshot`)共享同一编解码器;后端默认启用`packChunks` 配置只控制写入
### Surface 类型

View File

@@ -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
README.md: ef76bbcbd80ef5007426c2fea8537eceec3d4577
README.zh.md: 7eace737104310ac29f0c1e9db6d77aa911b8439
README.md: bbc41f1e0aa0c98a6e70ee54357675f1d7f05dbc
README.zh.md: 03e1246d5358138c633d2b19a9c186a3beec5a1e

View File

@@ -29,7 +29,7 @@ The app does not install commands, user interaction, session navigation, configu
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home shared by bash and local skill discovery. |
| `sessionTitle` | spine example limits | Durable fallback-title limits; titles remain off the ACP wire. |
| `persistenceRoot` | `./.sessions` | JSONL backend root and parent directory of the derived `session-query.db` index. |
| `packChunks` | `false` | Pack consecutive delta-chunk events in storage. |
| `packChunks` | `true` | Pack consecutive delta-chunk events in storage. |
| `persistenceCompression` | `zstd` | Checksummed Zstandard frames or raw `none`. |
| `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. |
| `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. |

View File

@@ -29,7 +29,7 @@ ACP 自动化服务器应用:默认 agent 主干、客户端通过 [`@deepseek
| `dshHome` | `$DSH_HOME``~/.dsh` | bash 与本地 skill 发现共享的 harness 主目录。 |
| `sessionTitle` | 主干示例限制 | 持久后备标题限制;标题仍不会进入 ACP wire。 |
| `persistenceRoot` | `./.sessions` | JSONL 后端根目录,以及派生 `session-query.db` 索引的父目录。 |
| `packChunks` | `false` | 在存储中打包连续的增量 chunk 事件。 |
| `packChunks` | `true` | 在存储中打包连续的增量 chunk 事件。 |
| `persistenceCompression` | `zstd` | 带校验和的 Zstandard 帧,或原始 `none`。 |
| `workspaceContext` | 必填 | Workspace 指令字节预算/配置,或 `false`。 |
| `skills` | 拥有者默认值 | Skill 注册表、本地提供方和面向模型的 skill 工具。 |

View File

@@ -55,7 +55,7 @@ export interface Config {
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `true`. */
packChunks?: boolean
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
@@ -89,7 +89,7 @@ export const Config: z<Config> = z.object({
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
packChunks: z.boolean().default(false),
packChunks: z.boolean().default(true),
persistenceCompression: JsonlCompressionSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,

View File

@@ -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
README.md: a0d718cf8bd0090df0409e7c60e6f7fd559b6f7d
README.zh.md: 307bef8efb506c2df7ef229e85b3224a8e7c29e1
README.md: ab6ecd28f12bd167aeac789d1565705e167d60f4
README.zh.md: 97d387a04fa4c658217e28619410a49b7e6d4ec0

View File

@@ -15,7 +15,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
```
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.
@@ -24,7 +24,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
| Key | Type | Notes |
|---|---|---|
| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). An existing root must be a readable directory; an absent root is created on first materialization. |
| `packChunks` | `boolean` (default `false`) | Write delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Off, the written logical layout is byte-identical to the pre-packing format; reading packed rows works regardless of this switch. Off by default while the snapshot goldens stay one-event-per-line — recording with packing on rewrites every fixture `session.jsonl`. |
| `packChunks` | `boolean` (default `true`) | Write eligible delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Set `false` for one-event-per-line diagnostics; reading packed rows works regardless of this write-side switch. |
| `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. |
`locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix.

View File

@@ -15,7 +15,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence``dsh-ses
```
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }``delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 存储记录是原样 `SessionEvent` JSON`packChunks` 写入的**打包分片行**`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session``packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist任何未识别内容原样存储。读取与布局无关`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
- 存储记录是原样 `SessionEvent` JSON或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session``packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist任何未识别内容原样存储。读取与布局无关`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
- 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。
@@ -24,7 +24,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence``dsh-ses
| 键 | 类型 | 说明 |
|---|---|---|
| `root` | `string` (required) | 所有会话文件的根目录。**无默认值**`process.cwd()` 默认值会随进程 cwd 变更bash 调用、子进程)而分散文件。现有根必须是可读目录;缺失根在第一次实体化时创建。 |
| `packChunks` | `boolean` (default `false`) | 将 delta 分片运行写为打包行(在真实编码会话上测得逻辑日志约小 60%)。关闭时,写入逻辑布局与打包前格式字节相同;无论开关如何,都能读取打包行。快照预期输出仍是每事件一行时默认关闭:开启打包记录会重写每个 fixture `session.jsonl`。 |
| `packChunks` | `boolean` (default `true`) | 将符合条件的 delta 分片连续段写为打包行(在真实编码会话上测得逻辑日志约小 60%)。设为 `false` 可用于每事件一行诊断;无论该写入侧开关如何,都能读取打包行。 |
| `compression` | `'zstd' \| 'none'` | 默认 `'zstd'``'none'` 保留换行分隔 UTF-8 文本。 |
`locate(meta)` 返回已解析项目/会话目录内固定 transcript 的 `{ kind: 'jsonl', path }`。它不执行文件系统 I/O可以在目录或文件存在前返回目标现有文件也只包含最后 flush 前缀。

View File

@@ -48,10 +48,9 @@ export interface Config {
/**
* Write runs of consecutive `assistant/chunk` delta events as packed
* `text-chunks`/`reasoning-chunks`/`tool-call-chunks` rows (lossless,
* ~60% smaller logs measured on a real session). Off by default while
* snapshot fixtures stay in the one-event-per-line layout: recording with
* packing on rewrites every golden `session.jsonl`. READING packed rows is
* unconditional — a log's layout never depends on this switch.
* ~60% smaller logs measured on a real session). Defaults to true; false
* keeps one `SessionEvent` per line for diagnostics. Reading packed rows is
* unconditional: a log's layout never depends on this switch.
*/
packChunks?: boolean
/** Physical encoding; defaults to checksummed Zstandard frames. */
@@ -80,7 +79,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
static Config: z<Config> = z.object({
root: z.string().required(),
packChunks: z.boolean().default(false),
packChunks: z.boolean().default(true),
compression: JsonlCompressionSchema,
})

View File

@@ -724,7 +724,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
})
})
describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () => {
describe('SessionPersistenceJsonl: default packed chunk rows', () => {
let ctx: Context
beforeEach(async () => {
root = await freshRoot()
@@ -732,7 +732,7 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () =>
await ctx.plugin(SessionStore)
// compression: 'none' — these tests assert the textual storage-record layout
// (row tags per line); packing is orthogonal to the physical encoding.
await ctx.plugin(SessionPersistenceJsonl, { root, packChunks: true, compression: 'none' })
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
})
afterEach(async () => { await ctx.fiber.dispose() })
@@ -754,7 +754,7 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () =>
]
}
it('writes a delta run as one text-chunks row and loads back identical events', async () => {
it('writes a delta run as one text-chunks row by default and loads back identical events', async () => {
const m = meta('packed', '/work')
const log = chunkRunLog()
await ctx.sessionPersistence.create(m)
@@ -768,6 +768,32 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () =>
expect(loaded.events).toEqual(log)
})
it('packChunks: false writes one event per line and still loads identical events', async () => {
const unpackedRoot = await freshRoot()
const unpacked = new Context()
await unpacked.plugin(SessionStore)
await unpacked.plugin(SessionPersistenceJsonl, {
root: unpackedRoot,
packChunks: false,
compression: 'none',
})
try {
const m = meta('unpacked', '/work')
const log = chunkRunLog()
await unpacked.sessionPersistence.create(m)
await unpacked.sessionPersistence.append(m.id, log)
const records = (await readFile(rawLogPath(unpackedRoot, '/work', m.id), 'utf8'))
.split('\n').filter(Boolean).slice(1)
.map(line => JSON.parse(line) as { type: string })
expect(records.filter(record => record.type === 'assistant/chunk')).toHaveLength(5)
expect(records.some(record => record.type === 'text-chunks')).toBe(false)
expect((await unpacked.sessionPersistence.load(m.id)).events).toEqual(log)
} finally {
await unpacked.fiber.dispose()
}
})
it('loads a mixed file: verbatim lines from an unpacked writer, then packed appends', async () => {
const m = meta('mixed', '/work')
const log = chunkRunLog()

View File

@@ -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
README.md: f3817a386a286e1dca40334fed7cb169643cb7e4
README.zh.md: 2f87e9ef7b29f65f81f8b464f59725c13a057003
README.md: 0dd8020a5939e1f1bdbb9b3947b850e0c71db786
README.zh.md: 667ba60203d6dbc989193e3ace965abfa9adeb51

View File

@@ -11,6 +11,8 @@ Four layers, importable separately:
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
```ts

View File

@@ -11,6 +11,8 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[
- **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`JSON-RPC id → 首次出现序列UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token按最长优先根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin`system-prompt.expected.md``tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会在对齐现有可变事件时间前展开打包时序 envelope因此切换打包/非打包布局无法移动后续记录;新分片碎片数组仍为权威数据。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。
消费方 `*.snapshot.ts` 就是场景表加一次工厂调用:
```ts

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* Temporary branch-convergence command for canonical packed session fixtures.
*
* @see ../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md
*/
import { writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { inspectSessionFixtureLayouts } from './session-fixture-layout.ts'
if (process.argv.length > 2) throw new Error('migrate:packed-session-fixtures takes no arguments')
const root = resolve(import.meta.dirname, '..')
const fixtures = inspectSessionFixtureLayouts(root)
const changed = fixtures.filter(fixture => fixture.source !== fixture.canonical)
for (const fixture of changed) {
writeFileSync(resolve(root, fixture.path), fixture.canonical)
console.log(fixture.path)
}
console.log(`packed session fixtures: ${changed.length} rewritten, ${fixtures.length} inspected`)

View File

@@ -0,0 +1,17 @@
/** Repository-wide canonical-layout check for committed session fixtures. */
import { resolve } from 'node:path'
import { expect, it } from 'vitest'
import { inspectSessionFixtureLayouts } from './session-fixture-layout.ts'
const root = resolve(import.meta.dirname, '..')
it('keeps every session-format JSONL fixture in canonical packed layout', () => {
const nonCanonical = inspectSessionFixtureLayouts(root)
.filter(fixture => fixture.source !== fixture.canonical)
.map(fixture => fixture.path)
expect(
nonCanonical,
'Run `pnpm run migrate:packed-session-fixtures` and commit the mechanical fixture rewrite.',
).toEqual([])
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session'
import { canonicalSessionFixture } from './session-fixture-layout.ts'
const HEADER = ' {"type":"session","version":0,"id":"fixture","createdAt":1,"delegationDepth":0} '
function chunkRun(): SessionEvent[] {
return Array.from({ length: 4 }, (_, index) => ({
type: 'assistant/chunk',
seq: index,
time: 10 + index,
data: {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: `part-${index}` },
},
}))
}
function unpackedFixture(): string {
return [HEADER, ...chunkRun().map(event => JSON.stringify(event)), ''].join('\n')
}
function decodedBody(content: string): SessionEvent[] {
return content.trimEnd().split('\n').slice(1)
.flatMap(line => decodeStorageRecord(JSON.parse(line) as unknown))
}
describe('canonicalSessionFixture', () => {
it('preserves the header line and packs an unpacked event run losslessly', () => {
const canonical = canonicalSessionFixture(unpackedFixture(), 'fixture.jsonl')
expect(canonical).toBeDefined()
expect(canonical?.split('\n')[0]).toBe(HEADER)
expect(JSON.parse(canonical?.split('\n')[1] ?? '{}')).toMatchObject({ type: 'text-chunks' })
expect(decodedBody(canonical ?? '')).toStrictEqual(chunkRun())
})
it('ignores JSONL whose first record is not a session header', () => {
expect(canonicalSessionFixture('{"type":"session_event"}\n{"value":1}\n')).toBeUndefined()
})
it('is idempotent for an already packed fixture', () => {
const packed = canonicalSessionFixture(unpackedFixture())
expect(packed).toBeDefined()
expect(canonicalSessionFixture(packed ?? '')).toBe(packed)
})
it('fails loud on malformed records after a session header', () => {
expect(() => canonicalSessionFixture(`${HEADER}\n{not-json}\n`, 'broken.jsonl'))
.toThrow(/broken\.jsonl:2: invalid JSON/)
})
})

View File

@@ -0,0 +1,120 @@
/** Canonical packed-row layout helpers for repository session fixtures. */
import { deepStrictEqual } from 'node:assert'
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { decodeStorageRecord, packChunkRuns, type SessionEvent } from '@deepseek-ai/dsh-session'
/** One repository session fixture and its canonical packed representation. */
export interface SessionFixtureLayout {
/** Repository-relative path with `/` separators. */
path: string
/** Current fixture bytes decoded as UTF-8. */
source: string
/** Canonical packed fixture bytes. */
canonical: string
}
interface RecordLine {
line: number
text: string
}
function recordLines(content: string): RecordLine[] {
return content.split(/\r?\n/).flatMap((text, index) => (
text.trim().length === 0 ? [] : [{ line: index + 1, text }]
))
}
function parseRecord(line: RecordLine, label: string): unknown {
try {
return JSON.parse(line.text) as unknown
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(`${label}:${line.line}: invalid JSON: ${detail}`, { cause: error })
}
}
function isSessionHeader(value: unknown): boolean {
return value !== null && typeof value === 'object' && (value as { type?: unknown }).type === 'session'
}
function decodeBody(lines: readonly RecordLine[], label: string): SessionEvent[] {
return lines.flatMap(line => decodeStorageRecord(parseRecord(line, label)))
}
function renderFixture(headerLine: string, events: readonly SessionEvent[]): string {
return [
headerLine,
...packChunkRuns(events).map(record => JSON.stringify(record)),
'',
].join('\n')
}
/**
* Canonicalize one JSONL document when its first record is a session header.
* The header line remains byte-identical; body records decode to logical events
* and re-encode with {@link packChunkRuns}. Non-session JSONL returns undefined.
*
* @param content - JSONL source text.
* @param label - path-like diagnostic label.
* @returns Canonical text for a session fixture, otherwise undefined.
*/
export function canonicalSessionFixture(content: string, label = '<session-fixture>'): string | undefined {
const lines = recordLines(content)
const header = lines[0]
if (header === undefined) return undefined
let headerValue: unknown
try {
headerValue = JSON.parse(header.text) as unknown
} catch {
return undefined
}
if (!isSessionHeader(headerValue)) return undefined
const events = decodeBody(lines.slice(1), label)
const canonical = renderFixture(header.text, events)
const canonicalLines = recordLines(canonical)
const decoded = decodeBody(canonicalLines.slice(1), label)
try {
deepStrictEqual(decoded, events)
} catch (error) {
throw new Error(`${label}: packed rewrite changed the decoded event stream`, { cause: error })
}
if (renderFixture(header.text, decoded) !== canonical) {
throw new Error(`${label}: packed rewrite is not idempotent`)
}
return canonical
}
/**
* Discover tracked and unignored untracked JSONL files through Git.
*
* @param root - repository root.
* @returns Stable repository-relative paths.
*/
export function discoverJsonlFiles(root: string): string[] {
return execFileSync(
'git',
['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '*.jsonl'],
{ cwd: root, encoding: 'utf8' },
).split('\0')
.filter(path => path.length > 0 && existsSync(resolve(root, path)))
.sort()
}
/**
* Inspect every repository JSONL whose first record is a session header.
*
* @param root - repository root.
* @returns Session fixtures with current and canonical text.
*/
export function inspectSessionFixtureLayouts(root: string): SessionFixtureLayout[] {
return discoverJsonlFiles(root).flatMap((path) => {
const source = readFileSync(resolve(root, path), 'utf8')
const canonical = canonicalSessionFixture(source, path)
return canonical === undefined ? [] : [{ path, source, canonical }]
})
}