diff --git a/.agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml b/.agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml
new file mode 100644
index 0000000000..511824cdc1
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml
@@ -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 .agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.md
+2026-07-26-packed-chunk-rows-by-default.md: c230c1f1faf5e597321654ebd01d60fae725f518
+2026-07-26-packed-chunk-rows-by-default.zh.md: e354efdf6bb68c02f30dc17c8d4ba17a495b61bd
diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md b/.agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md
rename to .agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.md
index bd4b3b9f77..c230c1f1fa 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md
+++ b/.agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.md
@@ -1,6 +1,7 @@
# Agent Note: Make packed chunk rows the default JSONL layout
Status: implemented
+Archived: 2026-09-01
English | [中文](2026-07-26-packed-chunk-rows-by-default.zh.md)
diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md b/.agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md
rename to .agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md
index eafe663215..e354efdf6b 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md
+++ b/.agents/notes/archived/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md
@@ -1,6 +1,7 @@
# Agent Note: 将打包分片行设为默认 JSONL 布局
Status: implemented
+Archived: 2026-09-01
[English](2026-07-26-packed-chunk-rows-by-default.md) | 中文
diff --git a/.agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.i18n.yaml b/.agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.i18n.yaml
new file mode 100644
index 0000000000..b632e8918b
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.i18n.yaml
@@ -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 .agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.md
+2026-08-15-packed-session-history-transport.md: 1fe8c78a89a0541475d10fae9ad643203f144c30
+2026-08-15-packed-session-history-transport.zh.md: b2aa5bf0bc0bc4952df29674f0b9ffe836368f73
diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md b/.agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
rename to .agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.md
index 01e36509b7..1fe8c78a89 100644
--- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
+++ b/.agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.md
@@ -1,6 +1,7 @@
# Agent Note: Carry packed chunk rows through session history
Status: implemented
+Archived: 2026-09-01
English | [中文](2026-08-15-packed-session-history-transport.zh.md)
diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md b/.agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.zh.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md
rename to .agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.zh.md
index 6ef847a14d..b2aa5bf0bc 100644
--- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.zh.md
+++ b/.agents/notes/archived/architecture/2026-08-15-packed-session-history-transport.zh.md
@@ -1,6 +1,7 @@
# Agent Note: 在会话历史中传输打包分片行
Status: implemented
+Archived: 2026-09-01
[English](2026-08-15-packed-session-history-transport.md) | 中文
diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json
index 18173d7d3d..8c0142d851 100644
--- a/.agents/notes/archived/manifest.json
+++ b/.agents/notes/archived/manifest.json
@@ -37,6 +37,9 @@
"architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml": "sha256:cf99eda0e58b49630d5f95792459d7095666fafbef61f614165d5cdd031b7118",
"architecture/2026-07-24-dsh-commander-argument-adapter.md": "sha256:705654c8a43bcd199f72c21a77d24ca8bfa02447aff1c7f3e4e820be61dcd562",
"architecture/2026-07-24-dsh-commander-argument-adapter.zh.md": "sha256:3844f02d7659d18caf5d39e1131ed775c789cbf92dc44b4a446c7d6468aa5d00",
+ "architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml": "sha256:41aa86c65f78e125ca2178295d02d5994d5e0f2e9359b1465777db1aba4a105b",
+ "architecture/2026-07-26-packed-chunk-rows-by-default.md": "sha256:2b4e14675d12a1fc07eb373ab8566c3e63c7c15e2b5a0b391e6acbc896c7359d",
+ "architecture/2026-07-26-packed-chunk-rows-by-default.zh.md": "sha256:e0b0e8a4ab529a3461c22f434cbbe1e7b0ea381592871caf7e3fe5157ef844ba",
"architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml": "sha256:7b9dbe8b4a340640610abe7e54fb29492d77a187c176996a53d0e1fc7c8e1945",
"architecture/2026-07-27-tui-chat-channel-module-split.md": "sha256:3e2cd43f306a18b3eaf9bac23e6bdc3a5dbdc7388b7c399ce71e4f71b8f71d2a",
"architecture/2026-07-27-tui-chat-channel-module-split.zh.md": "sha256:d6b84fdcd91a2693b72cf6884b3a0c39e56e571b2b694f630805d894a6ba292f",
@@ -52,6 +55,9 @@
"architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc",
"architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf",
"architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa",
+ "architecture/2026-08-15-packed-session-history-transport.i18n.yaml": "sha256:547b89497b009593db5acfae2a3b989f17b8392f5df73ef631b38f8f68f629f1",
+ "architecture/2026-08-15-packed-session-history-transport.md": "sha256:ec7f84d59eea95668a8cb6c92ae433a2b7e7b76446a27e57dd5dba05856f563f",
+ "architecture/2026-08-15-packed-session-history-transport.zh.md": "sha256:0d8eb5444557a18f76c68ce5ae9f0779c580eaddb921ee8651464baea34e0e53",
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml": "sha256:42bce930799cb511e9fb245dec5e26efd78bdab4c9b75f7393e37b40fbee4d10",
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.md": "sha256:4fe241f1b272278d9f3ca1a4431971220e1fa54411df043826ef6f59225bf949",
"architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md": "sha256:73178c9ec5abf571680d8facfb145cbadc1efbb2e67e3f039747c2f9cf4bb730",
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
index d664537d10..8f050e3cba 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
-2026-06-14-session-persistence.md: 989beb6f8cc65c8d033206a61b4408f3aecdbbc7
-2026-06-14-session-persistence.zh.md: 9dcdbffc7df89ce6fcd5341ef77f99e47643aa92
+2026-06-14-session-persistence.md: 8cca8a25a1795b50ad1b8282d0d3b0d1776fff24
+2026-06-14-session-persistence.zh.md: 2a4e8098625135c20d569c20bad37d1b36c55fe0
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
index 989beb6f8c..8cca8a25a1 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
@@ -15,11 +15,11 @@ The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append
Persistence is a **capability seam** with an abstract Service Definition ([capability seams](2026-06-13-capability-seams.md), the `dsh-shell` template), not loop or core logic:
1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`. 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 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.
+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. Current v2 writes one event per row; frozen v0 and v1 readers retain their historical packed-delta representation. [Checksummed Zstandard frames](2026-07-19-zstandard-jsonl-session-logs.md) are the default physical encoding, with raw lines configurable.
Key durable, contested choices:
-- **The canonical durable log persists every `SessionEvent` losslessly, including `assistant/chunk`.** JSONL storage may encode a consecutive delta run as one packed row, but logical readers reconstruct 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 validation of `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.
+- **The canonical durable log persists every current `SessionEvent` losslessly.** In v2, one `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log.
- **Ordinary writes append; a crashed turn is closed, never truncated.** Flushed current-generation events are never rewritten by normal persistence. A format migration leaves the exact physical source path, bytes, and inode unchanged, then publishes one re-encoded current successor at a previously absent canonical versioned filename after only edge-owned normalization and current crash repair. 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, cold inspection preserves its contiguous, parseable events and adds risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` to the in-memory logical view. `prepare` or `load` commits those closers before returning a recoverable view; the synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded during committed repair; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
- **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service and coordinator remain available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut.
- **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, and JSONL validates the decoded header. 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 boundary 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).)
@@ -27,10 +27,10 @@ Key durable, contested choices:
## Alternatives considered
-Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
+Each key choice above records its rejected alternative where the choice is stated: a **stream-filtered canonical log** — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
-Format versioning: the header carries a `version`; current Session and coordinator code accept only `SESSION_FORMAT_VERSION = 1`. JSONL event-body reads compose the static adjacent migration chain before constructing a Session, and the v0-to-v1 edge owns the former narrow import upgrades such as [pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md). V0 remains at suffixless `session.jsonl[.zstd]`, while v1 and later use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract.
+Format versioning: the header carries a `version`; current Session and coordinator code accept only `SESSION_FORMAT_VERSION = 2`. JSONL event-body reads compose the static v0-to-v1 and v1-to-v2 adjacent migration chain before constructing a Session; the first edge owns bounded legacy normalization, while the second owns Assistant stream embedding and dense reference remapping. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes tolerated during cold preparation; a future provider or write-ahead log needs its own power-loss and recovery contract.
## Consequences
-The Service Definition, JSONL provider, and metadata contract in `dsh-session` (`session.header`, the `create(id?, options?)` signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable `runPersistenceContract` suite holds the provider and future implementations to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, 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.
+The Service Definition, JSONL provider, and metadata contract in `dsh-session` (`session.header`, the `create(id?, options?)` signature) buy durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log. The reusable `runPersistenceContract` suite holds the provider and future implementations to the same append-only, contiguous-seq, lazy-materialization, logical-recovery, integer-metadata, and serializability semantics. Persisting the full logical log also settles event fidelity: every Assistant attempt retains its exact compact timed stream in one durable settlement.
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
index 9dcdbffc7d..2a4e809862 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
@@ -15,11 +15,11 @@ Status: implemented
持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑:
1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。
-2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
+2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。当前 v2 每行写入一个事件;冻结的 v0 与 v1 读取器保留其历史 packed-delta 表示。[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。
长期有效、存在争议的关键选择:
-- **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
+- **规范持久日志无损保留每个当前 `SessionEvent`。** 在 v2 中,一个 `assistant/message` 或 `assistant/attempt` 会嵌入一次 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。丢弃嵌入式 stream 成员很有吸引力,但会丢失 replay、时间、usage、部分失败与诊断事实。移除完整事件同样需要密集重编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该重写,而不是过滤规范日志。
- **普通写入仅追加;崩溃的轮次被关闭,而非截断。** 正常持久化绝不重写已刷入当前 generation 的事件。格式迁移保持精确物理源路径、字节与 inode 不变,再只经过迁移边拥有的归一化与当前崩溃修复,在此前不存在的规范具名版本文件下发布一个重新编码的当前后继。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务与 coordinator 继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。)
@@ -27,10 +27,10 @@ Status: implemented
## 曾考虑的替代方案
-上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
+上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 stream 的规范日志**会丢失 attempt 证据,而未通过显式迁移移除事件会破坏连续序号;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
-格式版本控制:header 携带一个 `version`;当前 Session 与协调器代码只接受 `SESSION_FORMAT_VERSION = 1`。JSONL 的事件正文读取会在构造 Session 前组合静态相邻迁移链,v0-to-v1 边拥有原有的范围受限导入升级,例如[消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md)。V0 保留在无后缀 `session.jsonl[.zstd]`,v1 及后续版本使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的追加与 flush 能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。
+格式版本控制:header 携带一个 `version`;当前 Session 与 coordinator 代码只接受 `SESSION_FORMAT_VERSION = 2`。JSONL 事件正文读取会在构造 Session 前组合静态 v0-to-v1 与 v1-to-v2 相邻迁移链;第一条迁移边拥有有界旧格式规范化,第二条拥有 Assistant stream 嵌入与密集引用重映射。V0 保留在无后缀 `session.jsonl[.zstd]`,正版本使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的追加与 flush 能承受冷准备时可容忍的尾部不完整写入;未来 provider 或 write-ahead log 需要自有的断电与恢复约定。
## 后果
-Service Definition、JSONL provider 与 `dsh-session` 中的元数据约定(`session.header`,`create(id?, options?)` 签名)带来持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整的逻辑日志还确定了事件保真度:即使 JSONL 将多个 `assistant/chunk` 打包到一条存储行中,每个事件也会精确保留。
+Service Definition、JSONL provider 与 `dsh-session` 中的元数据约定(`session.header`,`create(id?, options?)` 签名)带来持久恢复/fork、读取/replay 路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧 Session 访问。可复用 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、逻辑恢复、整数元数据与可序列化语义约束该 provider 与未来实现。持久化完整逻辑日志也确定了事件保真度:每个 Assistant attempt 都在一个持久 settlement 中保留其精确紧凑带时间 stream。
diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml
index 1a123eb416..ed7ac8f6d5 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-session-surface.md
-2026-06-18-session-surface.md: e9331ca2f827edd291d6365ef1356520c8f1927a
-2026-06-18-session-surface.zh.md: 26b58afe6aec3a192da94b2c29d4ec0c95d88a6b
+2026-06-18-session-surface.md: 0139cc4beba766e4e8b936594899649304234eaa
+2026-06-18-session-surface.zh.md: 0596d2a0425890924276265dd9cc6c32fcffb974
diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md
index e9331ca2f8..0139cc4beb 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md
+++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md
@@ -16,7 +16,7 @@ Add a **surface** — a derived, cached order of event sequences (the subset of
Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`):
-- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). A present `[]` is valid only on `assistant/message` and records a known empty provider stream; when the field is absent, a legacy or foreign event does not record which earlier events produced the message. Other surface events require a non-empty list when the field is present. Without these cited seqs, replay cannot validate that a replace-range operation names every event it removed.
+- **`sourceEventSeqs?: number[]`** — seq numbers of earlier events cited as sources, such as a `tool/call` cited by its result or surface nodes shadowed by a compaction marker. A present list is non-empty, unique, earlier, and known. V2 `assistant/message` embeds its provider stream and cannot carry this field. Without cited seqs, replay cannot validate that a replace-range operation names every event it removed.
- **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events.
### SurfaceOp: two operations
@@ -27,7 +27,7 @@ export type SurfaceOp =
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
```
-1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `tool/result` records its `tool/call` source.
+1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: `tool/result` records its `tool/call` source, while `assistant/message` owns its embedded stream directly.
2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface.
@@ -49,7 +49,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
### Invariants
-`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: only `assistant/message` may use an empty source-event list; references are unique, earlier, and known; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
+`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: source lists are non-empty, unique, earlier, and known; `assistant/message` carries no source list; replacement endpoints exist in surface order; and `sourceEventSeqs` covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and current loaded logs. Historical v0 validation and normalization belong to the v0-to-v1 edge rather than generic Session code.
diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md
index 26b58afe6a..0596d2a042 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md
@@ -16,7 +16,7 @@ Status: implemented
每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级):
-- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq,或被压缩标记遮蔽的 surface 节点)。出现的 `[]` 只在 `assistant/message` 上有效,表示已知为空的提供方流;旧格式或外部事件缺少该字段时,没有记录这条消息由哪些早期事件产生。其他 surface 事件一旦出现此字段,就必须是非空列表。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
+- **`sourceEventSeqs?: number[]`**:被引用为数据来源的早期事件 seq 编号,例如 result 引用的 `tool/call`,或被 compaction marker 遮蔽的 surface 节点。出现的列表必须非空、唯一、更早且已知。V2 `assistant/message` 嵌入其 provider stream,不能携带该字段。如果没有这些引用的 seq,回放就无法验证 replace-range 操作是否列出了它移除的每个事件。
- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。
### SurfaceOp:两种操作
@@ -27,7 +27,7 @@ export type SurfaceOp =
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
```
-1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:每个成功的 `assistant/message` 都记录完整的 `assistant/chunk` 来源集合(包括 `[]`),而 `tool/result` 记录其 `tool/call` 来源。
+1. **Append**:在尾部追加新事件的 seq。`user/message`、`assistant/message`、`tool/result`、`context/message` 使用此操作。agent loop(智能体循环)在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时记录 `sourceEventSeqs`:`tool/result` 记录其 `tool/call` 来源,`assistant/message` 则直接拥有其嵌入式 stream。
2. **Replace**:移除从 `start` 到 `end`(两端包含)的条目,并在其位置插入新事件的 seq。`start` 和 `end` 都必须存在于当前 surface;`start === end` 表示替换单个条目。该事件的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface seq。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
@@ -49,7 +49,7 @@ export type SurfaceOp =
### 不变式
-`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:只有 `assistant/message` 可以使用空的源事件列表;引用必须唯一、更早且已知;替换端点必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选的不变式服务提供的规则。
+`Session` 在始终启用的 seed/append 边界校验 `sourceEventSeqs` 与 `surfaceOp`:source list 必须非空、唯一、更早且已知;`assistant/message` 不携带 source list;replacement endpoint 必须存在于 surface 顺序中;`sourceEventSeqs` 必须覆盖每个被遮蔽的节点。这些是单记录接纳与存储投影规则,不是由可选 invariant service 提供的规则。
每个可进入 surface 的事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和当前已加载日志。历史 v0 的校验与规范化属于 v0-to-v1 边,而不属于通用 Session 代码。
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
index a0af3cb0e6..670e4f18e9 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
-2026-06-21-bounded-llm-request-recovery.md: 42bf460e52133b2a5471479fa3d7647e70092b48
-2026-06-21-bounded-llm-request-recovery.zh.md: 2a13f0a740348a5f74bd3d90120a148b25f2e870
+2026-06-21-bounded-llm-request-recovery.md: 6ae3915462c00eadcc550c80165fab7247a9170c
+2026-06-21-bounded-llm-request-recovery.zh.md: 82e2e783fffaedcb5bfaabe72f56f7c4176d3784
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
index 42bf460e52..6ae3915462 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
@@ -10,7 +10,7 @@ The [per-provider request retry policy](../feature/2026-07-24-provider-retry-pol
Provider adapters can fail by throwing during dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary normalizes thrown values to that terminal finish protocol before `dsh-agent-loop` receives them; middleware and result-processing defects remain thrown. The loop offers a terminal model-request failure to `agent/request-error`. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract.
-That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry reconstructs its next attempt from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
+That boundary is already safe for another request attempt. Each failed stream commits one log-only `assistant/attempt` with its exact compact stream, message derivation ignores it, tool calls are dispatched only after a successful terminal finish and assembled `assistant/message`, and a retry reconstructs its next attempt from the durable surface. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
The prior boundary left three narrower gaps.
@@ -38,7 +38,7 @@ interface LlmFailure {
}
```
-`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events.
+`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is preserved by the failed attempt's embedded stream.
`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. The final adapter boundary detaches those facts from adapter-thrown values and emits the appropriate terminal finish; unknown SDK exceptions receive an `UNKNOWN` payload. Exact thrown-object identity does not cross the LLM stream seam.
@@ -82,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada
### Keep attempts separate in the existing log
-A failed attempt may leave `assistant/chunk` events in its step, but it never appends `assistant/message` and never dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own chunks; only the final outcome closes the turn. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed attempt or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows.
+A failed attempt appends `assistant/attempt` with its embedded stream, but never appends a surface `assistant/message` or dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own settlement; only the final outcome closes the turn. UIs may render transient `assistant/live-chunk` updates while a step is open, then settle the failed attempt when `llm/retry` identifies it or `turn/end` records failure. Web validates the complete retry payload contract, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no surface Assistant node. Message derivation ignores `assistant/attempt`, and Web applies the same projection during history rebuild so refreshing cannot promote failed partials into model history or duplicate retry rows.
If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. While transient recovery continues, `llm/retry` is the durable home for each intermediate failure and delay; the terminal row exists only once `turn/end` records the error, and because exhausted recovery shares the failing turn, the turn's retry history never suppresses that row — the settled retry chain and the terminal error render side by side. No standalone final-error event or response-id vocabulary is added.
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
index 2a13f0a740..82e2e783ff 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
@@ -10,7 +10,7 @@ Status: implemented
提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回约定。
-该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志重建下一次尝试。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。
+该边界已能安全地再次发起请求。每个失败 stream 会提交一个包含精确紧凑 stream 的仅日志 `assistant/attempt`,message derivation 会忽略它;系统只会在 terminal finish 成功并组装 `assistant/message` 后分派工具调用,重试则从持久 surface 重建下一次 attempt。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次 attempt。
此前的边界还留有三个较窄的缺口。
@@ -38,7 +38,7 @@ interface LlmFailure {
}
```
-`code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。
+`code` 仍是 `HarnessError` 建立的 provider-neutral 机器路由分类;新字段是在 provider 边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后是 provider 发放的字符串。该 payload 有意不包含 `retryable`、`failover`、`partialOutput`、provider、model、phase 或 route id。是否可重试属于 policy,provider/model 已位于持久 request header 中,部分输出由失败 attempt 的嵌入式 stream 保留。
`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。最终适配器边界会从适配器抛出值中分离这些事实,并发出相应的终止 finish;未知 SDK 异常会获得 `UNKNOWN` 载荷。精确的抛出对象身份不会跨越 LLM 流 seam。
@@ -82,7 +82,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen
### 在现有日志中分隔尝试
-一次失败尝试可以在其步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试在失败的轮次与步骤内继续,从持久表层重建请求,并生成自己的分片;只有最终结果才会关闭该轮次。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败尝试,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将每条生产方关联的 `retryId` 重试链投影为稳定的一行,并用最新一次尝试更新该行,再从 `llm/retry-started` 与所属轮次、步骤边界的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。
+失败 attempt 会追加带嵌入式 stream 的 `assistant/attempt`,但绝不追加 surface `assistant/message` 或分派工具。重试在失败 turn 与 step 内继续,从持久 surface 重建请求,并产生自己的 settlement;只有最终结果才会关闭 turn。step 打开时,UI 可以渲染瞬态 `assistant/live-chunk` update;当 `llm/retry` 标识失败 attempt 或 `turn/end` 记录失败时,UI 再结算它。Web 会校验完整 retry payload contract,把每条 producer-correlated `retryId` chain 投影为稳定一行并更新到最新 attempt,再从 `llm/retry-started` 与所属 turn、step boundary 的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到 event 的时刻为计划延迟起点,而不是 Host event clock;它按向上取整且不低于 1 秒的秒数显示,只在未结算时动画,并把最新失败详情折叠在该行后。即使失败 attempt 没有 surface Assistant node,retry node 也会锚定自己的 trajectory turn。Message derivation 会忽略 `assistant/attempt`,Web 在历史重建时应用同一投影,因此刷新不会把失败 partial 提升进模型历史,也不会生成重复 retry row。
如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。暂时性恢复继续期间,`llm/retry` 是每次中间失败与延迟的持久归属位置;终态错误行只在 `turn/end` 记录错误后才存在,而由于耗尽的恢复与失败共享同一轮次,该轮次的重试历史绝不会抑制这一行——定格的重试链与终态错误并列渲染。本决策不增加独立的最终错误事件或响应 id 词汇。
diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml
index feb512101f..94a99ad44f 100644
--- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
-2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc
-2026-06-30-event-domain-semantics.zh.md: c3b12a167da0a41b792914d82a675a98b3a0b860
+2026-06-30-event-domain-semantics.md: f3e4686a1e9e14c9284de5a4bff16501c50aa907
+2026-06-30-event-domain-semantics.zh.md: f4bc92111666b0c30586222e14ee1aeab49ebb3f
diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
index 70da718b54..f3e4686a1e 100644
--- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
+++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
@@ -21,7 +21,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/
**Three domains, one job each, with a single boundary rule.**
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path.
-- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, and errors. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, as are the token stream (`assistant/chunk`) and mid-turn steering (a `user/message`).
+- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Interception waterfalls (`agent/pre-step`, `agent/request`, `agent/request-error`) transform, reject, or recover; awaited `agent/turn-stopping` observes the stop boundary; transient emits report lifecycle, status, inbox insertion/claim/discard, errors, and process-local `agent/assistant-stream` frames. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`; Assistant stream evidence becomes durable only inside one `assistant/message` or `assistant/attempt` settlement, and mid-turn steering is a durable `user/message`.
- **`tools/*` — the tool registry and execution pipeline.**
**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit.
diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md
index c3b12a167d..f4bc921116 100644
--- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md
@@ -21,7 +21,7 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)
**三个域,各司其职,以一条边界规则统一。**
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与回放投影共享同一路径。
-- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step`、`agent/request`、`agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 的插入、领取和丢弃,以及错误。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和轮次中途以 `user/message` 呈现的 steering(中途引导)同理。
+- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。拦截 waterfall(瀑布式事件)(`agent/pre-step`、`agent/request`、`agent/request-error`)负责变换、拒绝或恢复;awaited `agent/turn-stopping` 观察停止边界;瞬态 emit 报告生命周期、状态、inbox 插入、领取与丢弃、错误,以及进程本地 `agent/assistant-stream` frame。轮次和步骤边界不在此处——它们是从 `session/event` 读取的持久 Session event;Assistant stream 证据只在一个 `assistant/message` 或 `assistant/attempt` settlement 内变为持久事实,轮次中途 steering 则是持久 `user/message`。
- **`tools/*`——工具注册表与执行流水线。**
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml
index 76348b2cb2..881bd5a13c 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
-2026-07-19-zstandard-jsonl-session-logs.md: a79bc3907c4f6c02851ba1814f733684ce373898
-2026-07-19-zstandard-jsonl-session-logs.zh.md: 178420c126b68689983d5b01f4ffae29b17fe657
+2026-07-19-zstandard-jsonl-session-logs.md: 471419749ad65c811d0d1914e329f4d94597a54d
+2026-07-19-zstandard-jsonl-session-logs.zh.md: 382ea5d5d8d80e7314f5a718dab6e2773fa3614f
diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
index a79bc3907c..471419749a 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
@@ -6,7 +6,7 @@ English | [中文](2026-07-19-zstandard-jsonl-session-logs.zh.md)
## Problem
-The JSONL persistence backend keeps every `SessionEvent` verbatim, including high-volume `assistant/chunk` records. Raw text makes logs inspectable but spends storage and I/O on repeated JSON keys and model text. Compression must retain the existing append/fsync commit boundary, collision-safe first materialization, crash repair, and metadata-only listing; rewriting a whole compressed file after every turn would discard those properties.
+The JSONL persistence backend keeps every `SessionEvent` verbatim, including Assistant settlements with embedded model streams. Raw text makes logs inspectable but spends storage and I/O on repeated JSON keys and model text. Compression must retain the existing append/fsync commit boundary, collision-safe first materialization, crash repair, and metadata-only listing; rewriting a whole compressed file after every turn would discard those properties.
The encoding also has to remain explicit at the deployment boundary. Snapshot fixtures and external line readers require raw JSONL, while a backend cannot safely guess between compressed and raw artifacts in one root or silently migrate pre-release session data.
diff --git a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md
index 178420c126..382ea5d5d8 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量庞大的 `assistant/chunk` 记录。原始文本便于检查,但重复的 JSON 键和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有的 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复以及仅元数据列举;如果每轮都重写整个压缩文件,就会失去这些属性。
+JSONL 持久化后端会逐字保留每个 `SessionEvent`,包括嵌入模型 stream 的 Assistant settlement。原始文本便于检查,但重复的 JSON key 和模型文本会增加存储与 I/O 开销。压缩编码必须保留既有 append/fsync 提交边界、首次物化时的无冲突发布、崩溃修复与仅元数据列举;如果每轮都重写整个压缩文件,就会失去这些属性。
编码还必须在部署边界上保持显式。快照 fixture(测试前置数据)与外部逐行读取器需要原始 JSONL,而后端无法在同一根目录中安全猜测压缩产物与原始产物,也不能静默迁移预发布会话数据。
diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml
index 8baf8386d1..6c3733e600 100644
--- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
-2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6
-2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42
+2026-07-29-projected-token-usage-and-request-context.md: edcf80659a5456ae557a1591a7f290f5dac0c5f8
+2026-07-29-projected-token-usage-and-request-context.zh.md: 84ab782f23d08f6214767b17650a29d91885ce58
diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
index 75a05e5a0e..edcf80659a 100644
--- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
+++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
@@ -14,7 +14,7 @@ Context occupancy needs a numerator and a denominator that no existing surface c
Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present.
-`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value replaces the earlier sample from the same model attempt instead of double-counting it. A matching `llm/retry-started` boundary ends that replacement scope, so a retry with the same `(turn, step)` contributes a new attempt. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
+`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. It expands each `assistant/message` or `assistant/attempt` stream and takes the last usage sample; a message's top-level usage takes precedence over its embedded sample instead of double-counting it. `assistant/attempt` therefore preserves usage from failed requests. A matching `llm/retry-started` boundary opens a new attempt, so a retry with the same `(turn, step)` contributes separately. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
Token-meter also owns the shared pure attempt/Turn fold over durable events. It applies the same retry boundary while adding the stricter completeness and exact-total checks required by an exact per-Turn disclosure. A presentation consumer may select a complete Turn window and invoke that fold, but does not own or duplicate the accounting semantics.
diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md
index 7cce5989d7..84ab782f23 100644
--- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md
@@ -14,7 +14,7 @@ Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗
这两个值都是普通的持久会话投影状态。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个单元。
-`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;`assistant/message` 用量值会替换同一次模型 attempt 的先前样本,不会重复计数。匹配的 `llm/retry-started` 边界会结束该替换作用域,因此复用同一 `(turn, step)` 的重试会贡献一次新的 attempt。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
+`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。它会展开每个 `assistant/message` 或 `assistant/attempt` stream 并采用最后一个 usage sample;message 顶层 usage 优先于其嵌入式 sample,因此不会重复计数。`assistant/attempt` 由此保留失败请求的 usage。匹配的 `llm/retry-started` 边界会打开新 attempt,因此复用同一 `(turn, step)` 的重试会单独贡献用量。推理(reasoning)仍是输出的细分项。compaction 和 surface replacement 不会抹除先前计费。
token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。它采用相同的重试边界,并增加精确单轮次 disclosure 所需的更严格完整性与精确总量检查。展示消费方可以选择完整 Turn 窗口并调用该 fold,但不拥有或复制记账语义。
diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml
index 18313964ec..3d54f62991 100644
--- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
-2026-08-05-large-session-jsonl-restore-pipeline.md: 309d9dc6bdb5c3160f3e6e76a8318915df58fe59
-2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 28acd3ebe804dca22a0619c257ff3ad9c09500a9
+2026-08-05-large-session-jsonl-restore-pipeline.md: e87777cc407e50a0e4808b77c3a7d554659d62e7
+2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 32762bd19914423ef38c6472f0a8087e3e7e45fa
diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
index 309d9dc6bd..e87777cc40 100644
--- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
+++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md
@@ -30,7 +30,7 @@ The scanner stops retaining events at the first unparsable row or sequence gap b
### Restore admission
-Persistence transfers freshly materialized JSON values to `Session.fromRestore`. These values are detached, acyclic trees, and packed chunk rows expand into newly allocated events, so the restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
+Persistence transfers freshly materialized current JSON values to `Session.fromRestore`. These values are detached, acyclic trees; historical packed rows and adjacent migrations have already produced newly allocated v2 settlements. The restore-only path validates the fixed event envelope with one `for...in` and `switch`, dispatches current-shape checks by event discriminant, and iteratively freezes the owned graph with an explicit `pending` array and no cycle-tracking set. Surface validation records one transition plan and commits that plan when the exact candidate enters the log instead of planning the same event twice.
Borrowed seeds used by ordinary creation and fork paths still take a JSON snapshot and use the generic cycle-safe deep freeze. The specialization therefore changes only durable restoration; it does not weaken acceptance for caller-owned values.
diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md
index 28acd3ebe8..32762bd199 100644
--- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md
@@ -30,7 +30,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解
### 恢复准入
-持久化层把刚物化的 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的树,打包的分片行也会展开成新分配的事件。因此,恢复专用路径使用一次 `for...in` 与 `switch` 校验固定事件信封,按事件判别字段执行当前数据形状检查,并通过显式 `pending` 数组迭代冻结所拥有的对象图,不使用循环跟踪集合。`surface` 校验会记录一次转换计划;当同一个候选事件进入日志时,系统直接提交该计划,不再对同一事件规划两次。
+持久化把刚物化的当前 JSON 值转移给 `Session.fromRestore`。这些值是已分离且无环的 tree;历史 packed row 与相邻 migration 已经生成新分配的 v2 settlement。restore-only path 使用一次 `for...in` 与 `switch` 校验固定 event envelope,按 event discriminant 执行当前表示检查,并通过显式 `pending` array 迭代冻结 owned object graph,不使用 cycle-tracking set。`surface` 校验记录一次 transition plan,并在同一个 candidate event 进入 log 时提交该 plan。
普通创建与 fork 路径使用的借用 `seed` 仍会创建 JSON 快照,并使用支持循环检测的通用深度冻结。因此,这项特化仅改变持久恢复,不会放宽调用方所有值的准入要求。
diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml
index 0ff8938b30..9f33dde503 100644
--- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
-2026-08-08-bounded-session-persistence-write-batching.md: 20c16991b0be30ffe546a94c257bc65f86cb57eb
-2026-08-08-bounded-session-persistence-write-batching.zh.md: ac0384f4e28175922f84d23296dfb13848cf5dd3
+2026-08-08-bounded-session-persistence-write-batching.md: 97610093b9d80eded1d890f47f02b6b28bdd7f64
+2026-08-08-bounded-session-persistence-write-batching.zh.md: d076d5df3ba06021f73daa1a0d39b9f802cb7370
diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
index 20c16991b0..97610093b9 100644
--- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
+++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md
@@ -6,13 +6,13 @@ English | [中文](2026-08-08-bounded-session-persistence-write-batching.zh.md)
## Problem
-Streaming responses can emit many `assistant/chunk` events in a short interval. The persistence coordinator previously scheduled a provider append as soon as an idle queue received one event. Events arriving while that append was active shared a follow-up batch, but a fast provider could still produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix.
+One agent step can emit several durable events in a short interval: request metadata, one Assistant settlement, tool lifecycles, plugin facts, and execution boundaries. Scheduling a provider append as soon as an idle queue receives one event can therefore produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix.
-Dropping chunk events or replacing them with assembled messages would reduce logical storage, but it would also change the event log, replay, sequence numbers, timestamps, and the chunk seqs cited by assistant messages. The write-amplification problem does not require that larger semantic change.
+Assistant stream embedding reduces one high-volume event family, but write cadence remains a provider-neutral lifecycle concern for every other burst and for historical generations. The batching decision does not change event semantics or storage encoding.
### Quantified baseline
-Repository fixtures make the logical volume concrete. Decoding the current packed rows in [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) yields 2,098 events: 2,017 chunks (96.1%). Their unpacked JSONL lines occupy 332,647 of 379,225 event bytes (87.7%), while chunk packing reduces the committed file to 89,176 bytes and 182 storage rows, including 23 packed chunk rows. [`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) yields 813 events: 746 chunks (91.8%) and 118,935 of 184,821 unpacked event bytes (64.4%); its packed file is 84,917 bytes and 123 storage rows, including 14 packed rows. These are tracked deterministic fixtures, not a production workload distribution, but they demonstrate why deleting chunks would reduce logical volume and why the existing packed-row layout already removes much of their JSON envelope cost.
+Released-v1 repository fixtures established the original logical volume. Decoding the packed `goal-multi-turn-actions` generation yielded 2,098 events, including 2,017 chunks (96.1%); unpacked chunk lines occupied 332,647 of 379,225 event bytes, while the packed file used 89,176 bytes and 182 rows. The packed `permission-policy-context` generation yielded 813 events, including 746 chunks (91.8%); unpacked chunk lines occupied 118,935 of 184,821 event bytes, while the packed file used 84,917 bytes and 123 rows. These deterministic historical measurements explain why v2 embeds streams, but they are not a production workload distribution or a current-format size claim.
JSONL writes one Zstandard frame and fsync per durable append batch. Runtime files do not record former append boundaries, so fixture row counts cannot honestly be presented as fsync counts.
@@ -28,7 +28,7 @@ Each live Session receives a package-private `SessionWriteBehind`. When its pend
`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects.
-Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame without changing its on-disk format.
+Every admitted event remains durable in its original order and representation. The controller copies each event on admission; batching removes or rewrites no sequence, timestamp, surface metadata, embedded Assistant stream, or storage record. JSONL can therefore encode more events in one append frame without changing the Session format.
A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary.
@@ -36,7 +36,7 @@ This decision supersedes only the immediate scheduling cadence in [Collapse live
## Alternatives considered
-**Do not persist streaming chunk events.** Rejected here: it changes the event-sourced authority and recovery semantics rather than only physical write cadence. The existing [assembled-message rejection](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) remains the guardrail until a no-information-loss replacement defines replay, fork, cited source-event links, sequence, and crash behavior independently. The [packed-row decision](2026-07-26-packed-chunk-rows-by-default.md) remains the complementary JSONL storage-size optimization.
+**Use one settlement per Assistant attempt instead of batching writes.** The [v2 Assistant stream decision](2026-09-01-v2-embedded-assistant-streams.md) provides that no-information-loss event model and reduces Assistant event cardinality. It does not replace bounded batching for other adjacent events, historical-generation publication, or providers with the same append interface.
**Write only at semantic checkpoints.** Rejected: it maximizes batching but makes the ordinary crash-loss window depend on a separately mounted policy. Bounded background writes preserve progress between checkpoints while mandatory flushes keep their stronger ordering contract.
@@ -50,10 +50,10 @@ The controller tests use a fake clock to prove the fixed, non-resetting 200 ms w
## Consequences
-High-frequency event bursts normally produce fewer durable append operations while preserving the exact logical event count. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches.
+High-frequency event bursts normally produce fewer durable append operations while preserving the exact admitted event sequence. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches.
This decision does not cap pending event count or bytes behind a slow provider, and it does not reduce the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule.
An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait.
-The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. `SESSION_FORMAT_VERSION` remains unchanged.
+The deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; the provider retains only durable storage primitives. Batching itself never changes `SESSION_FORMAT_VERSION`.
diff --git a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md
index ac0384f4e2..d076d5df3b 100644
--- a/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.zh.md
@@ -6,13 +6,13 @@ Status: implemented
## 问题
-流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次 provider 追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果 provider 速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。
+一个 agent 步骤可以在短时间内发出多个持久事件:请求元数据、一个 Assistant settlement、工具生命周期、插件事实与执行边界。只要空闲队列收到一个事件就立即调度 provider 追加,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀。
-丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量,但也会改变事件日志、回放、序列号、时间戳,以及助手消息引用的分片 seq。写放大问题不要求采取这项语义变化更大的方案。
+Assistant stream 嵌入会减少一个高频事件 family,但对于其他事件突发与历史 generation,写入节奏仍是 provider-neutral 生命周期问题。批处理决策不会改变事件语义或存储编码。
### 量化基线
-仓库 fixture(测试前置数据)让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../snapshots/web/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码,可得到 2,098 个事件,其中 2,017 个是分片(96.1%)。这些分片解包后的 JSONL 行共 332,647 字节,占全部事件 379,225 字节的 87.7%;分片打包则把仓库中的已提交文件缩小到 89,176 字节和 182 个存储行,其中包括 23 个打包分片行。[`permission-policy-context`](../../../../snapshots/web/permission-policy-context/session.jsonl) 可得到 813 个事件,其中 746 个是分片(91.8%);这些分片解包后的 JSONL 行共 118,935 字节,占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节,共 123 个存储行,其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture,不代表生产工作负载分布;但它们说明了删除分片为何会降低逻辑数据量,也说明现有打包行布局已经消除了大量 JSON 包装开销。
+已发布 v1 仓库 fixture 建立了原始逻辑数据量。解码 packed `goal-multi-turn-actions` generation 得到 2,098 个事件,其中 2,017 个是 chunk(96.1%);解包的 chunk 行占 379,225 个事件字节中的 332,647 字节,而 packed 文件使用 89,176 字节与 182 行。packed `permission-policy-context` generation 得到 813 个事件,其中 746 个是 chunk(91.8%);解包的 chunk 行占 184,821 个事件字节中的 118,935 字节,而 packed 文件使用 84,917 字节与 123 行。这些确定性历史测量解释了 v2 为何嵌入 stream,但不代表生产工作负载分布或当前格式大小。
JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 次数。
@@ -28,7 +28,7 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
-每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,而无需改变其磁盘格式。
+每个已接纳事件仍会按原有顺序和表示持久化。控制器会在接纳时复制每个事件;批处理不会删除或改写任何序号、时间戳、surface 元数据、嵌入式 Assistant stream 或存储记录。因此,JSONL 可以在一个追加 frame 中编码更多事件,而无需改变 Session 格式。
后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
@@ -36,7 +36,7 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
## 备选方案
-**不持久化流式分片事件。** 这里不采纳:这会改变事件溯源的权威地位及恢复语义,而不只是改变物理写入节奏。在无信息损失的替代方案独立定义回放、fork、引用源事件的关联、序列和崩溃行为之前,现有的[拒绝仅保留组装消息的决策](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md)仍是防护规则。[打包行决策](2026-07-26-packed-chunk-rows-by-default.zh.md)仍是配套的 JSONL 存储体积优化。
+**使用每个 Assistant attempt 一个 settlement 代替批处理写入。** [v2 Assistant stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)提供该无信息损失事件模型,并减少 Assistant 事件基数。它不能替代其他相邻事件、历史 generation 发布或使用同一 append 接口的 provider 所需的有界批处理。
**仅在语义检查点写入。** 不采纳:此方案会最大化批处理,却让普通的崩溃丢失窗口取决于另行挂载的策略。有界后台写入会在检查点之间持久化进度,而强制 flush 继续提供更强的顺序约定。
@@ -50,10 +50,10 @@ JSONL provider 公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 No
## 后果
-高频事件突发通常会减少持久化追加操作,同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
+高频事件突发通常会减少持久化追加操作,同时保持已接纳事件序列完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
本决策不会限制因 provider 缓慢而积压的待处理事件数量或字节数,也不会减少解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放约定,而不是再引入一条隐式计时器规则。
接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。
-deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 保持不变。
+deep 模块统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化;provider 仍只负责持久存储原语。批处理本身绝不改变 `SESSION_FORMAT_VERSION`。
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
index c60b8ae3ba..1e49aa359b 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520
-2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d
+2026-08-09-client-conversation-node-assembly.md: abf03b52c174156b650f5c88425991892cc7615f
+2026-08-09-client-conversation-node-assembly.zh.md: f4c8c7e1675dcf5b2bc846cb0731cc914532090a
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
index 4831c22617..abf03b52c1 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
@@ -51,7 +51,7 @@ Each `(kind, id)` has at most one start Match. A second start fails immediately;
#### `match(event)`
-`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A `chunkrow/*` event can only be an update; the Assembler rejects it as a start, and `start()` receives a `ConversationStartMatch` containing a standard `SessionEvent`.
+`match(event)` reads only the current `SessionEventLike` and returns `{ id, role: 'start' | 'update' }` or `null`. It cannot access a Context, history, a Reader, a Location, or the view envelope. A Client-only `assistant/live-chunk` event can only be an update; the Assembler rejects every transient start, and `start()` receives a `ConversationStartMatch` containing a durable `SessionEvent`.
This restriction makes one scalar event or packed run's routing cost depend only on the number of registered Definitions. The Assembler never scans a Definition's historical Contexts to decide which one owns an update.
@@ -110,7 +110,7 @@ Dependencies point strictly from earlier starts to later starts, so transitive r
#### `update(context, match)`
-`update()` handles a post-start scalar or packed Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. A Definition that consumes Assistant deltas folds each matching `chunkrow/*` value as one batch without constructing member events.
+`update()` handles a post-start durable or transient Match that `match()` has already routed exactly to the current `(kind, id)`. It does not decide which Context owns the input. An Assistant Definition folds each `assistant/live-chunk` update directly and expands an embedded `assistant/message` or `assistant/attempt` stream during history replay.
The Assembler invokes `update()` in ascending `seq` order. A live tail update can apply incrementally; any non-tail insertion, newly loaded start, or invalidated dependency causes a complete replay from `start()`.
@@ -262,7 +262,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec
| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Append message IDs to persistent splice state; materialize once per claim and expose the shared current claimed batch to Message |
| Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering |
| Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes |
-| Assistant / `assistant-step` | `turn:step` | `step/start` | Scalar or packed `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data |
+| Assistant / `assistant-step` | `turn:step` | `step/start` | Live `assistant/live-chunk`, durable `assistant/message` or `assistant/attempt`, and same-step Retry | Aggregate blocks, usage, first-token time, settlement evidence, and retry-hidden state, then publish same-key Step data |
| Tool / `tool-call` | Root call ID | Root `tool/call` | Root result and Code Dispatch start/result | Aggregate the root, children, and parent Map; Dispatch Events route exactly through `rootCallId` |
| Command / `command` | Command ID | `command/run` | `command/done` and compact lifecycle/checkpoint Events carrying a source command ID | Aggregate command outcome and manual-compaction evidence |
| Automatic Compaction / `compaction` | Compaction ID | `compaction/start` without a source command ID | Summary, end, and replacement checkpoint | Aggregate summary/checkpoint; sufficient checkpoint evidence supports fallback without a start |
@@ -382,7 +382,7 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran
**Define a reverse State fold for backward history scanning.** Rejected: every business would maintain two inverse algorithms, and deletion, non-invertible aggregation, and cross-Context dependencies would be difficult to keep equivalent. Ordered Matches followed by forward replay from start preserve one business meaning.
-**Add a separate chunk-run matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. `ChunkRowEvent` uses the existing `match(event)` and `update(context, match)` lifecycle while making packed handling explicit through its `chunkrow/*` discriminant.
+**Add a separate live-stream matcher and update lifecycle.** Rejected: a second Definition path would duplicate dispatch, replay, publication, and Context types. Client-only `assistant/live-chunk` and durable settlements use the existing `match(event)` and `update(context, match)` lifecycle; only the event discriminator and stream expansion differ.
**Make Inbox a first-class engine concept or one window-wide Context.** Rejected: Inbox is ordinary business State and does not belong in the generic engine. Per-splice instantaneous State plus a strictly backward Reader supports prepend, append, and Message lookup together.
diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
index 37463d0543..f4c8c7e167 100644
--- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
@@ -51,7 +51,7 @@ Assembler 使用 `conversationContextKey(kind, id)` 组合无碰撞 key;不同
#### `match(event)`
-`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }` 或 `null`。它拿不到 Context、历史、Reader、Location 或 view envelope。`chunkrow/*` event 只能作为 update;Assembler 会拒绝 packed start,`start()` 接收的 `ConversationStartMatch` 只包含标准 `SessionEvent`。
+`match(event)` 只读取当前 `SessionEventLike`,返回 `{ id, role: 'start' | 'update' }` 或 `null`。它拿不到 Context、history、Reader、Location 或 view envelope。Client-only `assistant/live-chunk` event 只能作为 update;Assembler 会拒绝每个 transient start,`start()` 接收的 `ConversationStartMatch` 只包含持久 `SessionEvent`。
这项限制使单条 scalar event 或 packed run 的路由成本只随已注册 Definition 数量增长。Assembler 不会为了判断一条 update 属于谁而遍历该 Definition 的历史 Context。
@@ -110,7 +110,7 @@ Reader 每次查询都记录 `{ key, revision, windowGap }` 依赖。命中前
#### `update(context, match)`
-`update()` 只处理已经由 `match()` 精确路由到当前 `(kind, id)` 的 post-start scalar 或 packed Match。它不判断 input 属于哪个 Context。消费 Assistant delta 的 Definition 会把每个匹配的 `chunkrow/*` 值作为一个 batch fold,而不构造成员 event。
+`update()` 只处理已由 `match()` 精确路由到当前 `(kind, id)` 的 post-start durable 或 transient Match。它不判断 input 属于哪个 Context。Assistant Definition 会直接 fold 每个 `assistant/live-chunk` update,并在 history replay 期间展开嵌入式 `assistant/message` 或 `assistant/attempt` stream。
Assembler 按 `seq` 升序调用 `update()`。实时尾部 update 可以直接增量应用;任何非尾部证据插入、start 补齐或依赖失效都会从 `start()` 完整 replay。
@@ -262,7 +262,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新
| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 把消息 ID 追加到持久 splice state;每次 claim 只 materialize 一次,并向 Message 暴露共享的当前 claimed batch |
| Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering |
| Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 |
-| Assistant / `assistant-step` | `turn:step` | `step/start` | scalar 或 packed `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data |
+| Assistant / `assistant-step` | `turn:step` | `step/start` | Live `assistant/live-chunk`、持久 `assistant/message` 或 `assistant/attempt`、同 step Retry | 聚合 block、usage、首 token 时间、settlement 证据与 retry-hidden state,再发布同 key Step data |
| Tool / `tool-call` | root call ID | root `tool/call` | root result、Code Dispatch start/result | 聚合 root、children 和 parent Map;Dispatch Event 用 `rootCallId` 精确路由 |
| Command / `command` | command ID | `command/run` | `command/done`、带 source command ID 的 compact lifecycle/checkpoint | 聚合 command outcome 和手动压缩证据 |
| Automatic Compaction / `compaction` | compaction ID | 无 source command ID 的 `compaction/start` | summary、end、replacement checkpoint | 聚合 summary/checkpoint;checkpoint 足够时可在缺 start 下 fallback |
@@ -382,7 +382,7 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
**为历史反扫定义逆向 State fold。** 拒绝:每个业务都要维护互为逆运算的两套逻辑,删除、非可逆聚合和跨 Context 依赖很难保持一致。统一 Matches 后从 start 正序 replay 只有一套业务语义。
-**增加独立的 chunk-run matcher 与 update lifecycle。** 拒绝:第二条 Definition 路径会重复 dispatch、replay、publication 与 Context 类型。`ChunkRowEvent` 使用既有 `match(event)` 与 `update(context, match)` lifecycle,并通过 `chunkrow/*` discriminator 明确标记 packed 处理。
+**增加独立 live-stream matcher 与 update lifecycle。** 拒绝:第二条 Definition path 会重复 dispatch、replay、publication 与 Context type。Client-only `assistant/live-chunk` 与持久 settlement 使用既有 `match(event)` 和 `update(context, match)` lifecycle;只有 event discriminator 与 stream expansion 不同。
**把 Inbox 做成引擎一级公民或一个窗口级 Context。** 拒绝:Inbox 是普通业务状态,不应污染通用引擎;逐 splice 瞬间态加严格前序 Reader 同时支持 prepend、append 和 Message 查询。
diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml
index d9823a5e88..b471d553d6 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
-2026-08-10-cancelled-stream-prefix-finalize.md: fd397a02663908f5984b4e1798d1b1759b140c79
-2026-08-10-cancelled-stream-prefix-finalize.zh.md: 44adb2ff4163cd1904a9a93895c99519bae2f234
+2026-08-10-cancelled-stream-prefix-finalize.md: 1e6fe59bcc1b323941b69d0e9232f0bcf9e56f53
+2026-08-10-cancelled-stream-prefix-finalize.zh.md: 7aa81f179b80925ef47a8820f8a80c1ef37ec5ea
diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
index fd397a0266..1e6fe59bcc 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md
@@ -6,23 +6,23 @@ English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md)
## Problem
-A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
+A cancelled stream can leave transient chunks that clients have rendered while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap.
The model history must contain assistant content that remains visible to the user after cancellation.
## Decision
-`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`.
+`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, compact stream accumulator, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and the exact embedded timed stream. The append precedes the committed `agent/assistant-stream` end frame, `step/end`, and the aborted `turn/end`.
-`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request.
+`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends `assistant/attempt` instead of a surface message. Provider `error` and `aborted` finishes also commit `assistant/attempt` before `agent/request-error`, so their streams remain durable without contributing failed-request content to model history.
-Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
+Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provider information. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results.
## Alternatives considered
**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user.
-**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
+**Assemble the prefix from the embedded attempt during projection.** `deriveMessages()` and Client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative surface message for the prefix. This also expands model history beyond the three `SurfaceEventType` events.
**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result.
@@ -32,8 +32,8 @@ Chat and Trajectory Conversation Definitions read `interrupted` from the durable
Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason.
-Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy.
+Terminal provider errors retain their stream in `assistant/attempt` but keep its content out of model history. Only the user's cancellation decision turns visible delivered text into an interrupted surface message.
## Testing
-`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.
+`packages/core/agent-loop/tests/cancel.spec.ts` covers content, embedded streams, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix attempt. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both Client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications.
diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md
index 44adb2ff41..7aa81f179b 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md
@@ -6,23 +6,23 @@ Status: implemented
## Problem
-被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
+被取消的流可能留下 Client 已经渲染的瞬态 chunk,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。
模型历史必须包含取消后仍对用户可见的 assistant 内容。
## Decision
-`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。
+`ReactLoopAgent.step()` 在消费模型 stream 期间捕捉取消,此时 `BlockAssembler`、紧凑 stream accumulator 与 provider route 可以确定已送达前缀。loop 把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 与精确嵌入式带时间 stream。该追加先于 committed `agent/assistant-stream` end frame、`step/end` 和记录 aborted 的 `turn/end`。
-`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。
+`BlockAssembler.interruptedBlocks()` 按 stream 顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` block。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空 block 和未闭合的未知 block 类型。返回结果为空时追加 `assistant/attempt`,而不是 surface message。Provider `error` 与 `aborted` finish 也会在 `agent/request-error` 前提交 `assistant/attempt`,因此其 stream 保持持久,但失败请求内容不会进入模型历史。
-Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
+Chat 和 Trajectory Conversation Definition 从持久 message 读取 `interrupted`。Chat 渲染 Stopped marker,Trajectory 则在 `step/end` 后把 provider request 保持在 error 生命周期,并保留持久 result seq 与 provider 信息。工具执行期间的取消遵循工具调度器约定,因为 assistant message 已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。
## Alternatives considered
**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。
-**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。
+**在投影时从嵌入式 attempt 组装前缀。** `deriveMessages()` 与 Client Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 surface message。这还会让模型历史超出三类 `SurfaceEventType` 事件。
**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。
@@ -32,8 +32,8 @@ Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`
取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。
-终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略。
+终局 provider error 会在 `assistant/attempt` 中保留其 stream,但不让内容进入模型历史。只有用户的取消决策会把可见的已送达文本变成 interrupted surface message。
## Testing
-`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。
+`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖 content、嵌入式 stream、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀 attempt。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts` 与 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种 Client 投影。keyless `cancel` ACP snapshot 与 `goal-round-driver` goal snapshot 覆盖组装应用。
diff --git a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml
deleted file mode 100644
index d8af00effc..0000000000
--- a/.agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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 .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
-2026-08-15-packed-session-history-transport.md: 01e36509b7ad2c878ae4ea04c3a10f029e1b8f3d
-2026-08-15-packed-session-history-transport.zh.md: 6ef847a14da1b4ec1bd59e5aaad9093162b42d84
diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml
index d7990f37fe..13c4e2fb9b 100644
--- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
-2026-08-21-deepseek-llm-api-request-extensions.md: 018b93115f5376affd86a4da3c76f0f367ba9ed0
-2026-08-21-deepseek-llm-api-request-extensions.zh.md: 4bc0f0c992445c5897069b68efa47fdba46dfdb4
+2026-08-21-deepseek-llm-api-request-extensions.md: eadbe2a5f17de446c345120f9a6aeeeb531c43b4
+2026-08-21-deepseek-llm-api-request-extensions.zh.md: f45210adbc075c30484754ce7f8c2b65b515be6c
diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
index 018b93115f..eadbe2a5f1 100644
--- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
+++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
@@ -73,7 +73,7 @@ The receiver would also need to traverse the tagged tree, resolve paths into the
### Why not omit assistant chunks or overlapping event data?
-About 98% of the measured real-session events were `assistant/chunk`. Omitting chunks after reference encoding reduced the complete identity JSON by another 84.79% for late enable and 6.49% for steady state, but it prevents lossless canonical-log reconstruction and leaves `assistant/message.sourceEventSeqs` pointing to absent events. Fuzzy or normalized substitutions have the same reconstruction defect.
+About 98% of the measured v1 real-session events were `assistant/chunk`. Omitting them after reference encoding reduced the complete identity JSON by another 84.79% for late enable and 6.49% for steady state, but prevented lossless reconstruction and left message provenance dangling. V2 embeds compact streams in attempt settlements; `dsh_session_log` still sends every current canonical event whole and does not omit those embedded records. Fuzzy or normalized substitutions have the same reconstruction defect.
**Keep the upload cursor only in memory.** Rejected because a normal process restart would resend the entire Session. A canonical acceptance event makes restart recovery best-effort durable without another storage backend; the remaining crash window produces allowed duplicates.
diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md
index 4bc0f0c992..f45210adbc 100644
--- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md
@@ -73,7 +73,7 @@ Status: implemented
### 为什么不省略 assistant 分片或重叠事件数据?
-实测真实会话事件中约 98% 为 `assistant/chunk`。在引用编码后省略分片,会让完整未压缩 JSON 在延迟启用场景进一步减少 84.79%,在稳态场景进一步减少 6.49%,但这会阻止权威日志的无损重建,并让 `assistant/message.sourceEventSeqs` 指向缺失事件。模糊替换或规范化替换也存在同一重建缺陷。
+实测 v1 真实 Session event 中约 98% 为 `assistant/chunk`。在引用编码后省略它们,会让完整 identity JSON 在延迟启用场景进一步减少 84.79%,在稳态场景进一步减少 6.49%,但会阻止无损重建并让 message provenance 悬空。V2 把紧凑 stream 嵌入 attempt settlement;`dsh_session_log` 仍会完整发送每个当前规范 event,且不会省略这些嵌入式 record。模糊或规范化替换也有相同重建缺陷。
**只在内存中保留上传游标。** 已否决,因为普通进程重启会重发完整会话。权威接受事件让重启恢复获得尽力而为的持久性,无需另一存储后端;剩余崩溃窗口只会产生允许的重复。
diff --git a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml
index 0fb0ceca81..007f722536 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
-2026-08-31-live-assistant-stream-frames.md: 9e848cadbfa71f8281caf14207b36d05f1f95d99
-2026-08-31-live-assistant-stream-frames.zh.md: dc672d6d8af31f1b45694299125d3f91cb1874ee
+2026-08-31-live-assistant-stream-frames.md: b91dde47259c2e455a2a2068c644a66aef136836
+2026-08-31-live-assistant-stream-frames.zh.md: a32124f24db94c04360f36baca7be29f5c062b7d
diff --git a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
index 9e848cadbf..b91dde4725 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.md
@@ -6,19 +6,19 @@ English | [中文](2026-08-31-live-assistant-stream-frames.zh.md)
## Problem
-The session log keeps every `assistant/chunk` so replay, cold reads, telemetry, and request reconstruction observe one durable v1 history. A live consumer also needs prompt frame-by-frame presentation while a request runs. Treating a transient presentation update as a new durable event would change persistence semantics and make a process-lifetime concern survive restart.
+The v2 session log keeps one `assistant/message` or `assistant/attempt` settlement with the complete compact timed stream, so replay, cold reads, telemetry, and request reconstruction observe one durable history. A live consumer also needs prompt frame-by-frame presentation while a request runs. Treating a transient presentation update as another durable event would restore token-level event cardinality and make a process-lifetime concern survive restart.
## Decision
-`dsh-agent-loop` emits scoped `agent/assistant-stream` frames for each model attempt. `start`, `chunk`, and `end` carry a branded process-local `LlmAttemptId`; every emitted frame advances one Session-local revision. The `start` frame captures a safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, and `end.index` equals the next chunk position. Stream acquisition and its final cancellation check occur before `start`; a failure there emits no frame, while every started attempt emits a terminal `end`. The loop appends every v1 `assistant/chunk` before its matching live chunk frame, records that exact `legacyChunkSeq`, and appends the final `assistant/message` before a committed end frame. The existing authenticated Session-follow accepts an explicit Web opt-in, opens with a cached active-attempt baseline, and carries durable events and cursorless frames in one FIFO. Each follower captures a local arrival ordinal with the opening baseline and drops buffered frames at or before that cut; frame revisions can restart at one with a replacement Agent, so they do not define the opening cut. If the durable opening snapshot precedes the Assistant baseline, a baseline may already acknowledge a chunk whose durable event remains buffered; the Web Session publishes that event when it arrives because the baseline's exact `legacyChunkSeq` proves its matching frame. A final message arriving after an active opening remains staged until the matching `end.index` and ordered provenance arrive; an earlier retry at the same Turn and Step remains visible. Revision, dense-index, or provenance gaps re-open follow and replace the baseline. The TypeScript and Python SDK protocols do not expose these frames. The durable log remains the source of replay and model history.
+`dsh-agent-loop` emits scoped `agent/assistant-stream` frames for each model attempt. `start`, `chunk`, and `end` carry a branded process-local `LlmAttemptId`; every frame advances one Session-local revision. The start frame captures a safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, chunk timestamps are reused by the compact stream, and `end.index` equals the next chunk position. The loop appends the final `assistant/message` or `assistant/attempt` before a committed end frame names that event and seq; an abandoned end names no durable event. Authenticated Session-follow accepts an explicit Web opt-in, opens with a cached active-attempt compact baseline, and carries durable events and cursorless frames in one FIFO. Each follower captures a local arrival ordinal with the opening baseline and drops buffered frames at or before that cut; frame revisions can restart at one with a replacement Agent, so they do not define the opening cut. An opening between a durable settlement and its end frame reconstructs the active Client-only `assistant/live-chunk` updates, stages only the settlement owned by that attempt's `startedTime`, Turn, and Step, and releases it after the matching end index, type, and seq; an earlier retry at the same Turn and Step remains visible. Revision, dense-index, or settlement gaps reopen follow and replace the baseline. The TypeScript and Python SDK protocols do not expose these frames. Durable settlements remain the source of replay and model history; the [v2 stream decision](2026-09-01-v2-embedded-assistant-streams.md) owns their representation.
## Alternatives considered
-- **Replace `assistant/chunk` with a live-only stream** — rejected because cold reads, replay, telemetry, and the completed assistant message's source references require the durable raw chunk history.
-- **Add a durable assistant-stream event type** — rejected because process-local attempts, revisions, and reconnect presentation are not facts that survive restart or affect model reconstruction.
+- **Keep only the live stream** — rejected because cold reads, replay, telemetry, usage accounting, and failed-attempt diagnostics require the durable embedded stream.
+- **Persist each live frame as its own event** — rejected because process-local attempt ids, revisions, and reconnect presentation do not survive restart or affect model reconstruction; one settlement owns the durable stream.
- **Use an unbranded request string as the attempt key** — rejected because consumers need an opaque identity that cannot be confused with provider request IDs or durable Session IDs.
- **Let UI Chat subscribe to a second live source** — rejected because the Session object owns stream reconciliation and UI Conversation is the sole event-source subscriber; a second source would make settlement order target-dependent.
## Consequences
-The Web client renders in-memory chunks before persistence flush while retaining one durable v1 history, without changing `SESSION_FORMAT_VERSION` or the chunk-row encoding. A process restart has no active assistant frames; reconnect and cold replay use durable records. Cursorless notifications never advance the journal cursor, and notifications observed during durable gap repair wait for the replacement page. That page has no Assistant baseline, so the Client clears transient attempts and lets the held notification reopen follow once for an atomically paired page and baseline. The frame declaration remains agent-scoped, so a listener observes only its owning Agent unless it explicitly registers globally.
+The Web client renders in-memory chunks before the attempt settles while retaining one durable v2 history. A process restart has no active Assistant frames; reconnect can restore only the baseline held by the current process, while cold replay expands durable settlements. Cursorless notifications never advance the journal cursor, and notifications observed during durable gap repair wait for the replacement page. The frame declaration remains agent-scoped, so a listener observes only its owning Agent unless it explicitly registers globally.
diff --git a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md
index dc672d6d8a..a32124f24d 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-live-assistant-stream-frames.zh.md
@@ -6,19 +6,19 @@ Status: implemented
## 问题
-Session log 保留每个 `assistant/chunk`,因此重放、冷读、遥测和请求重建都能观察同一份持久 v1 历史。实时消费方还需要在请求运行时逐帧呈现。把短暂的呈现更新当作新的持久事件会改变持久化语义,并让只属于进程生命周期的事实跨重启保留。
+v2 Session log 通过一个 `assistant/message` 或 `assistant/attempt` settlement 保留完整紧凑带时间 stream,因此 replay、冷读、遥测与请求重建都能观察同一份持久历史。实时消费方还需要在请求运行时逐帧呈现。把瞬态呈现 update 当作另一种持久事件,会恢复 token 粒度事件基数,并让只属于进程生命周期的事实跨重启保留。
## 决定
-`dsh-agent-loop` 为每次模型尝试发出作用域内的 `agent/assistant-stream` 帧。`start`、`chunk` 和 `end` 带有带品牌的进程本地 `LlmAttemptId`;每个已发出的帧都会推进一次 Session 本地 revision。`start` 帧会把壁钟时间捕获为安全整数 `startedTime`,chunk index 从零开始连续递增,`end.index` 等于下一个 chunk 位置。循环会先取得 stream 并执行最终取消检查,再发出 `start`;这些步骤失败时不发出任何帧,而每个已开始的尝试都会发出终态 `end`。循环在匹配的实时 chunk 帧之前追加每个 v1 `assistant/chunk`,记录精确的 `legacyChunkSeq`,并在已提交的 end 帧之前追加最终 `assistant/message`。现有的已认证 Session-follow 接受显式 Web opt-in,以缓存的活跃尝试 baseline 打开,并在一个 FIFO 中携带持久事件和无 cursor 的帧。每个 follower 会随 opening baseline 捕获本地到达序号,并丢弃该 cut 及之前的 buffered frame;replacement Agent 的 frame revision 可以从一重新开始,因此 revision 不定义 opening cut。如果持久 opening snapshot 早于 Assistant baseline,baseline 可能已经确认一个持久事件仍在 buffer 中的 chunk;该事件到达时,Web Session 会依据 baseline 中精确的 `legacyChunkSeq` 已证明其匹配帧而直接发布。活跃 opening 之后到达的最终 message 会保持暂存,直到匹配的 `end.index` 与有序来源到达;同一 Turn 和 Step 中更早的 retry 仍保持可见。revision、连续 index 或来源缺口会重新打开 follow 并替换 baseline。TypeScript 和 Python SDK 协议不公开这些帧。持久 log 仍然是重放和模型历史的真源。
+`dsh-agent-loop` 为每次模型 attempt 发出作用域内的 `agent/assistant-stream` frame。`start`、`chunk` 和 `end` 带有带品牌的进程本地 `LlmAttemptId`;每个 frame 都会推进一次 Session 本地 revision。start frame 会把壁钟时间捕获为安全整数 `startedTime`,chunk index 从零开始密集递增,chunk 时间戳会被紧凑 stream 复用,`end.index` 等于下一个 chunk 位置。loop 会在 committed end frame 命名事件与 seq 前追加最终 `assistant/message` 或 `assistant/attempt`;abandoned end 不命名持久事件。已认证 Session-follow 接受显式 Web opt-in,以缓存的活跃 attempt 紧凑 baseline 打开,并在一个 FIFO 中携带持久事件和无 cursor frame。每个 follower 会随 opening baseline 捕获本地到达序号,并丢弃该 cut 及之前的 buffered frame;replacement Agent 的 frame revision 可以从一重新开始,因此 revision 不定义 opening cut。opening 位于持久 settlement 与对应 end frame 之间时,会重建活跃的 Client-only `assistant/live-chunk` update,只暂存由该 attempt 的 `startedTime`、Turn 与 Step 所有的 settlement,并在匹配的 end index、type 与 seq 到达后释放;同一 Turn 和 Step 中更早的 retry 仍保持可见。revision、密集 index 或 settlement 缺口会重新打开 follow 并替换 baseline。TypeScript 和 Python SDK 协议不公开这些 frame。持久 settlement 仍是 replay 与模型历史的真源;其表示由 [v2 stream 决策](2026-09-01-v2-embedded-assistant-streams.zh.md)负责。
## 曾考虑的替代方案
-- **用仅实时的流替换 `assistant/chunk`**:不采用,因为冷读、重放、遥测和已完成 assistant message 的来源引用都需要持久的原始 chunk 历史。
-- **添加持久的 assistant-stream 事件类型**:不采用,因为进程本地尝试、revision 和重连呈现不是会跨重启保留或影响模型重建的事实。
+- **只保留 live stream**:不采用,因为冷读、replay、遥测、usage 记账与失败 attempt 诊断需要持久嵌入式 stream。
+- **把每个 live frame 作为独立事件持久化**:不采用,因为进程本地 attempt id、revision 与重连呈现不会跨重启保留或影响模型重建;一个 settlement 拥有持久 stream。
- **用未加品牌的请求字符串作为尝试键**:不采用,因为消费方需要一个不透明身份,不能把它与 provider request ID 或持久 Session ID 混淆。
- **让 UI Chat 订阅第二个实时 source**:不采用,因为 Session 对象拥有 stream 对账,UI Conversation 是唯一的 event-source 订阅方;第二个 source 会使结算顺序依赖 target。
## 影响
-Web client 可以在 persistence flush 前渲染内存 chunk,同时保留一份持久 v1 历史,而不改变 `SESSION_FORMAT_VERSION` 或 chunk-row 编码。进程重启后没有活跃 assistant 帧;重连和冷重放使用持久记录。无 cursor 的通知绝不推进 journal cursor,在持久缺口修复期间观察到的通知会等待 replacement page。该 page 不携带 Assistant baseline,因此 Client 会清空瞬态尝试,并让 held notification 重新打开 follow 一次,以取得原子配对的 page 与 baseline。帧声明保持 agent 作用域,因此监听器只观察所属 Agent,除非它显式全局注册。
+Web client 可以在 attempt settlement 前渲染内存 chunk,同时保留一份持久 v2 历史。进程重启后没有活跃 Assistant frame;重连只能恢复当前进程持有的 baseline,冷 replay 则展开持久 settlement。无 cursor 通知绝不推进 journal cursor,在持久缺口修复期间观察到的通知会等待 replacement page。frame 声明保持 agent 作用域,因此监听器只观察所属 Agent,除非它显式全局注册。
diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml
similarity index 56%
rename from .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml
rename to .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml
index 58dcf1ee20..3472483442 100644
--- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml
@@ -1,6 +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 .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md
-2026-07-26-packed-chunk-rows-by-default.md: bd4b3b9f773afbf6aa7e88d51b6e842d6634c222
-2026-07-26-packed-chunk-rows-by-default.zh.md: eafe6632150aadd74395f4d0f09d064fb703a03d
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
+2026-09-01-v2-embedded-assistant-streams.md: 2f01905ba844b4ef4bdbce6e2f193c77d61ff8e4
+2026-09-01-v2-embedded-assistant-streams.zh.md: d4e84463986ddb3113181d2920973fa54cacb678
diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
new file mode 100644
index 0000000000..2f01905ba8
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
@@ -0,0 +1,68 @@
+# Agent Note: Embed Assistant streams in v2 attempt settlements
+
+Status: implemented
+
+English | [中文](2026-09-01-v2-embedded-assistant-streams.zh.md)
+
+## Problem
+
+Token-sized `assistant/chunk` events preserve exact stream order, timing, usage, terminal state, replay metadata, and partial failed output, but making each chunk a top-level Session event repeats envelopes throughout persistence, telemetry, history transport, indexing, and client assembly. Physical packed rows reduce JSONL bytes without reducing logical event count or the work of consumers that receive the canonical stream.
+
+Storing only assembled successful messages would remove that overhead but lose failed and abandoned output, token boundaries, timestamps, and deterministic provider replay. The durable record needs one unit per model attempt without reducing the evidence that replay, diagnostics, cancellation recovery, usage accounting, snapshots, and UI history rely on.
+
+Changing event cardinality also changes Session sequence numbers. A released migration must preserve the relative order of unrelated events, rewrite every declared same-Session reference, retain the exact fork cut, and refuse any relationship it cannot preserve semantically.
+
+## Decision
+
+Session format v2 has no top-level `assistant/chunk` event. Each model attempt commits one durable settlement containing `stream: AssistantStreamRecord[]`:
+
+- `assistant/message` is the surface settlement for a successful response or a cancelled response with visible assembled content. It embeds the exact compact timed stream beside the assembled message, optional usage, and optional `interrupted: true` marker.
+- `assistant/attempt` is log-only. It preserves the stream for a failed, retried, cancelled, or crash-tail attempt that commits no surface message, so diagnostics and accounting do not fabricate model-visible history.
+
+`AssistantStreamAccumulator` snapshots each chunk once. Consecutive text, reasoning, or tool-argument deltas for the same block become one compact run with its first timestamp, exact timestamp gaps, and one array member per original delta. Every other chunk remains a timestamped raw record. `expandAssistantStream()` strictly validates and reconstructs the exact timed sequence; compaction never joins delta boundaries.
+
+The current v2 validator requires the embedded stream to reproduce a non-empty `assistant/message`'s content, usage, and replay state. An empty stream remains valid for a migrated legacy message that had no source chunks. `assistant/message` cannot carry obsolete chunk `sourceEventSeqs`; ordinary user and tool surface provenance remains available.
+
+### Live presentation and durable replay
+
+`agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop appends the complete `assistant/message` or `assistant/attempt` before a committed end frame names its type and sequence. An abandoned end has no settlement.
+
+The Web follow adapter opts into these cursorless frames. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages the matching settlement until the committed end, and reopens follow on a revision gap. A reconnect baseline carries the active attempt's compact prefix. Paged history, replay, telemetry, token accounting, and cold UI assembly read the durable embedded stream rather than the live frames.
+
+### Released v1 to v2 migration
+
+The adjacent migration validates the complete frozen v1 artifact, groups chunks by turn, step, terminal boundary, and exact message provenance, and then substitutes one settlement per attempt. A successful group's chunks move into its message. An unclaimed group becomes `assistant/attempt` at the last consumed chunk's position. Unrelated interleaved events retain their relative order, and survivors receive dense v2 sequence numbers.
+
+The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
+
+The v2 physical header requires `isSeeded` and stores no numeric cut. A seeded artifact marks its exact cut with `session/end-seed { inherited: true }`; decoding derives the cut from the last tagged marker. The v2 codec writes one durable event per physical row and range-encodes only `sourceEventSeqs`. Frozen v0 and v1 codecs retain packed-row decoding for their immutable historical generations.
+
+Generation selection and publication follow the [released Session migration decision](2026-08-31-released-session-format-migrations.md): the source path, bytes, and inode remain unchanged, only the final version-named successor is published, and retained predecessors provide neither fallback nor downgrade support.
+
+## Verification
+
+The compact-stream tests pin exact accumulation and expansion for text, reasoning, tool arguments, raw chunks, timestamp gaps, malformed records, and detached snapshots. The v1-to-v2 tests cover successful and failed attempts, interleaving, dense sequence and reference remapping, seed-cut insertion and split refusal, strict source and target validation, one-row v2 encoding, provenance ranges, raw and Zstandard publication, and no-write current reads.
+
+The manual performance acceptance compares current v2 catalog dispatch with a direct-current read of the same physical input across three runs, 100 warmup pairs, and 600 measured pairs. It requires every pooled median and p95 regression to remain within 5%; the accepted run's worst p95 regression was 2.201%. `--smoke` reports a non-gating diagnostic sample.
+
+Agent-loop tests pin durable-before-end ordering, interrupted visible prefixes, failed and retry attempts, abandonment, usage, and replay metadata. Session Controller and Conversation tests pin live transient display, reconnect baselines, committed settlement release, history replay, Chat and Trajectory parity, while TypeScript and Python SDK snapshots pin the external event representation.
+
+## Alternatives considered
+
+**Persist only assembled successful messages.** This loses partial failed output, timing, token boundaries, usage from attempts without a message, and exact deterministic replay. `assistant/attempt` and the embedded compact stream preserve those facts without adding them to model history.
+
+**Keep top-level chunks and pack only physical rows.** This preserves the v1 logical representation but leaves sequence density, telemetry volume, wire envelopes, Client entries, and consumer dispatch proportional to token count. Historical codecs still decode that representation; it is not the current event model.
+
+**Carry packed chunk rows through the history API.** This reduces wire and Client work for v1 but gives the Client a second event vocabulary and keeps transport coupled to token-row cardinality. The current API carries scalar durable settlements plus a separate live transient stream.
+
+**Store the stream in a sidecar or replay-only fixture.** This splits one attempt's message and evidence across durability owners and cannot give ordinary resumed sessions the same failed-output and timing facts. The settlement is the atomic owner.
+
+**Redirect references from consumed chunks to their settlement.** A chunk and an attempt settlement are not interchangeable facts. Refusal prevents a migration from silently changing the meaning of plugin-owned references.
+
+## Consequences
+
+Current logs, telemetry, history pages, and cold Client assembly scale by model attempts rather than token chunks while retaining exact stream evidence inside each settlement. Live presentation remains incremental and intentionally process-local.
+
+One settlement can be large, and v1-to-v2 migration materializes the whole artifact plus its sequence map. The closed alpha inventory refuses unknown v1 events and undeclared references instead of guessing. Consumers that need individual chunks call `expandAssistantStream()` and must not infer durability from `agent/assistant-stream`.
+
+Migration changes sequence numbers after consumed v1 chunks, so every same-Session reference belongs to an explicit rewrite rule. This constraint makes future cardinality-changing migrations expensive by design and keeps silent semantic redirection out of the format chain.
diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md
new file mode 100644
index 0000000000..d4e8446398
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md
@@ -0,0 +1,68 @@
+# Agent Note: 在 v2 attempt settlement 中嵌入 Assistant stream
+
+Status: implemented
+
+[English](2026-09-01-v2-embedded-assistant-streams.md) | 中文
+
+## 问题
+
+Token 粒度的 `assistant/chunk` 事件会保留精确的 stream 顺序、时间、usage、terminal state、replay metadata 与失败时的部分输出,但让每个 chunk 成为顶层 Session event 会在持久化、遥测、历史传输、索引和 Client 组装中重复信封。物理 packed row 可以减少 JSONL 字节,却不会减少逻辑事件数,也不会减少接收规范 stream 的消费方工作量。
+
+只存储组装后的成功 message 可以消除这些开销,但会丢失失败与放弃的输出、token 边界、时间戳和确定性 provider replay。持久记录需要让每个模型 attempt 只占一个单位,同时不减少 replay、诊断、取消恢复、usage 记账、snapshot 与 UI 历史依赖的证据。
+
+改变事件基数也会改变 Session 序号。已发布迁移必须保留无关事件的相对顺序、改写每个已声明的同 Session 引用、保留精确 fork 切点,并拒绝任何无法保持语义的关系。
+
+## 决策
+
+Session format v2 没有顶层 `assistant/chunk` 事件。每个模型 attempt 提交一个包含 `stream: AssistantStreamRecord[]` 的持久 settlement:
+
+- `assistant/message` 是成功响应或具有可见组装内容的已取消响应所对应的 surface settlement。它在组装 message 旁嵌入精确的紧凑带时间 stream、可选 usage 与可选 `interrupted: true` marker。
+- `assistant/attempt` 只进入日志。它保留失败、重试、取消或崩溃尾部 attempt 的 stream;这些 attempt 没有提交 surface message,因此诊断与记账不会虚构模型可见历史。
+
+`AssistantStreamAccumulator` 对每个 chunk 只快照一次。同一 block 的连续 text、reasoning 或 tool argument delta 会变成一个紧凑 run,包含首个时间戳、精确时间戳间隔和每个原始 delta 对应的一个数组成员。其他 chunk 保留为带时间戳的 raw record。`expandAssistantStream()` 会严格校验并重建精确的带时间序列;压缩绝不会合并 delta 边界。
+
+当前 v2 校验器要求嵌入式 stream 能复现非空 `assistant/message` 的 content、usage 与 replay state。对于没有源 chunk 的已迁移旧 message,空 stream 仍然有效。`assistant/message` 不能携带已停用的 chunk `sourceEventSeqs`;普通 user 与 tool surface provenance 保持可用。
+
+### 实时呈现与持久回放
+
+`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 命名其类型和序号前追加完整的 `assistant/message` 或 `assistant/attempt`。abandoned end 没有 settlement。
+
+Web follow adapter 显式选择接收这些无 cursor frame。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,把匹配的 settlement 暂存到 committed end,并在 revision 缺口时重新打开 follow。重连 baseline 携带活跃 attempt 的紧凑前缀。分页历史、replay、遥测、token 记账与冷 UI 组装读取持久嵌入式 stream,而不是 live frame。
+
+### 已发布 v1 到 v2 迁移
+
+相邻迁移会校验完整的冻结 v1 产物,按 turn、step、terminal boundary 与精确 message provenance 对 chunk 分组,再为每个 attempt 替换一个 settlement。成功分组的 chunk 移入其 message。未被认领的分组会在最后一个被消费 chunk 的位置变成 `assistant/attempt`。无关的交错事件保持相对顺序,存活事件获得密集 v2 序号。
+
+该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
+
+v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产物用 `session/end-seed { inherited: true }` 标记其精确切点;解码从最后一个 tagged marker 推导切点。v2 编解码器为每个持久事件写一条物理行,并且只对 `sourceEventSeqs` 做范围编码。冻结的 v0 与 v1 编解码器继续为不可变历史 generation 解码 packed row。
+
+Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-released-session-format-migrations.zh.md):源路径、字节与 inode 保持不变,只发布最终具名版本 successor;保留 predecessor 不提供 fallback 或 downgrade 支持。
+
+## 验证
+
+紧凑 stream 测试固定 text、reasoning、tool argument、raw chunk、时间戳间隔、格式错误 record 与分离 snapshot 的精确累积和展开。v1 到 v2 测试覆盖成功与失败 attempt、交错、密集序号与引用重映射、seed 切点插入与切分拒绝、严格源与目标校验、每行一个事件的 v2 编码、provenance range、原始与 Zstandard 发布,以及无写入的当前读取。
+
+手工 performance acceptance 会在三轮、100 组 warmup pair 与 600 组 measured pair 下,把当前 v2 catalog dispatch 与同一物理输入的 direct-current 读取比较。它要求每个 pooled median 与 p95 regression 保持在 5% 以内;已接受运行的最差 p95 regression 为 2.201%。`--smoke` 报告不参与 gate 的诊断 sample。
+
+Agent-loop 测试固定先持久后 end 的顺序、中断的可见前缀、失败与重试 attempt、abandonment、usage 与 replay metadata。Session Controller 与 Conversation 测试固定实时瞬态显示、重连 baseline、committed settlement 发布、历史回放以及 Chat 与 Trajectory 一致性;TypeScript 与 Python SDK snapshot 固定外部事件表示。
+
+## 备选方案
+
+**只持久化组装后的成功 message。** 这会丢失部分失败输出、时间、token 边界、没有 message 的 attempt usage,以及精确确定性 replay。`assistant/attempt` 与嵌入式紧凑 stream 会保留这些事实,且不把它们加入模型历史。
+
+**保留顶层 chunk,只打包物理行。** 这会保留 v1 逻辑表示,却让序号密度、遥测量、wire 信封、Client entry 与消费方 dispatch 继续与 token 数成正比。历史编解码器仍然解码该表示;它不是当前事件模型。
+
+**通过历史 API 传递 packed chunk row。** 这会减少 v1 的 wire 与 Client 工作,却让 Client 拥有第二套事件词汇,并让传输继续与 token-row 基数耦合。当前 API 携带标量持久 settlement,并使用独立的实时瞬态 stream。
+
+**把 stream 存在 sidecar 或 replay-only fixture 中。** 这会把一个 attempt 的 message 与证据拆给不同持久性 owner,也无法让普通恢复 Session 获得相同的失败输出与时间事实。settlement 是原子 owner。
+
+**把被消费 chunk 的引用重定向到其 settlement。** Chunk 与 attempt settlement 不是可互换事实。拒绝可以防止迁移悄然改变插件自有引用的含义。
+
+## 后果
+
+当前日志、遥测、历史页与冷 Client 组装按模型 attempt 而非 token chunk 扩展,同时在每个 settlement 内保留精确 stream 证据。实时呈现保持增量,并且有意仅存在于进程内。
+
+一个 settlement 可能很大,v1 到 v2 迁移会物化完整产物及其序号映射。封闭的 Alpha 清单会拒绝未知 v1 事件与未声明引用,而不会猜测。需要单独 chunk 的消费方调用 `expandAssistantStream()`,并且绝不能从 `agent/assistant-stream` 推断持久性。
+
+迁移会改变被消费 v1 chunk 之后的序号,因此每个同 Session 引用都必须属于显式改写规则。该约束有意让未来的基数变化迁移保持昂贵,并防止格式链执行无声的语义重定向。
diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml
index f234acb000..f2c184f557 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
-2026-07-21-compaction-summary-prefix-cache-reuse.md: 08ceb820ee415cbac9aded7a6b4f55933dc64cf3
-2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 2d796c8518c703d872ebcec4f548a0e52d91994b
+2026-07-21-compaction-summary-prefix-cache-reuse.md: a1066cd9925b37a05ea06c22b60b81d6107cd336
+2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 3f487f63250713570ccc7962ba0afab869bc656a
diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
index 08ceb820ee..a1066cd992 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
@@ -29,7 +29,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th
- **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache.
- **Send only the shadowed region without the `system`/`tools` head** — rejected: a differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs.
- **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse.
-- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — rejected: the durable `compaction/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream.
+- **A dedicated Agent-backed summarization sub-session for snapshot replay** — rejected: the durable `compaction/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream. Creating a sub-session solely to obtain an Assistant settlement adds an unrelated lifecycle.
## Consequences
diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md
index 2d796c8518..3f487f6325 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md
@@ -29,7 +29,7 @@ Status: implemented
- **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。
- **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。
- **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。
-- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——否决:持久的 `compaction/summary` 事件会记录成功本地调用的位置和完整输出,而显式调用标记可防止回放把模板或远程输出当作本地流。
+- **为 snapshot replay 专门建立 Agent-backed summarization sub-session**——否决:持久 `compaction/summary` event 会记录成功本地调用的位置与完整输出,显式 call marker 可防止 replay 把 template 或 remote output 当作本地 stream。仅为获得 Assistant settlement 而创建 sub-session 会增加无关 lifecycle。
## 后果
diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.i18n.yaml
index 60c2a60e42..9e02707a9b 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md
-2026-07-31-english-compaction-checkpoints.md: dc95ed187a6ba5b86800f06ebefb2776995f9421
-2026-07-31-english-compaction-checkpoints.zh.md: 1dc98b43e5bf2ca66cb3480b8124af6e7a9479b2
+2026-07-31-english-compaction-checkpoints.md: a1123b3d679a8bab2dbd9ecdc7b2079a78ffe28a
+2026-07-31-english-compaction-checkpoints.zh.md: 1543d87493a9cbfb28b18f00afa4ee4d3a074179
diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md b/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md
index dc95ed187a..a1123b3d67 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md
@@ -25,4 +25,4 @@ The requirement is integrated into the first sentence of the trailing compaction
- New checkpoints normalize narrative context into English while retaining the exact strings that future tool use and code work depend on.
- Existing checkpoint structure, compaction routing, and cache alignment are unchanged; only the final user instruction is different.
-- The direct summarization call remains outside transcript snapshots because it emits no `assistant/chunk` events. The real-loop regression instead asserts the exact final instruction received by the summarization request.
+- The direct summarization call remains outside transcript snapshots because it emits no Agent-owned Assistant settlement. The real-loop regression instead asserts the exact final instruction received by the summarization request.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.zh.md
index 1dc98b43e5..1543d87493 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.zh.md
@@ -25,4 +25,4 @@ Status: implemented
- 新检查点会将叙述性上下文规范化为英语,同时保留未来工具使用和代码工作所依赖的精确字符串。
- 既有检查点结构、压缩路由和缓存对齐保持不变;只有最后一条 user 指令不同。
-- 直接摘要调用仍不纳入 transcript(文本记录)快照,因为它不会发出 `assistant/chunk` 事件。真实循环回归改为断言摘要请求收到的精确最终指令。
+- 直接 summarization call 仍不纳入 transcript snapshot,因为它不会发出 Agent-owned Assistant settlement。真实 loop regression 改为断言 summarization request 收到的精确最终 instruction。
diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml
index 16713035e7..006a91edf1 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
-2026-08-10-subagent-empty-terminal-message-output.md: 24bab01ad844a5b48e0bf6fe0fc54df6403f4bb7
-2026-08-10-subagent-empty-terminal-message-output.zh.md: 28d8d85e316fe4770e55685e9d5641fda846c5c5
+2026-08-10-subagent-empty-terminal-message-output.md: 6ecb0ef254bd9734664aac71fe63c1eb3caf45b7
+2026-08-10-subagent-empty-terminal-message-output.zh.md: bcc6cad60fb5c791c328da1057e836cc1267d11b
diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
index 24bab01ad8..6ecb0ef254 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md
@@ -6,11 +6,11 @@ English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md)
## Problem
-The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message records usage only. Three consumers selected the child's output independently and treated that usage record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in `assistant/chunk` events reported `[]`.
+The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message retains its stream and usage but contributes no output blocks. Three consumers selected the child's output independently and treated that record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in an embedded Assistant stream reported `[]`.
## Decision
-`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
+`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty Assistant message; without one, select accumulated `text-delta` content from embedded `assistant/message` and `assistant/attempt` streams or a chunk-only transport; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)`, `pushText(text)`, and `collect()`. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete Assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason.
The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message presents the optional safe Provider diagnostic owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md) after the stop-reason headline and appends the child's partial text afterward. The parent model receives the failure, separate infrastructure detail, and available assistant output without conflating them.
diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md
index 28d8d85e31..bcc6cad60f 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md
@@ -6,11 +6,11 @@ Status: implemented
## 问题
-当 `max-tokens` 步骤只组装了工具调用块时,agent loop(智能体循环)会追加一条空内容的 `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的工具调用;这条消息仅记录 usage。三个消费方独立选取子 agent 的输出,并把这条 usage 记录当成输出。进程内驱动的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 优先于累积的文本。在被 max-tokens 截断的多步轮次中,最后那条空消息导致 `SubagentResult.output`、工具结果、遥测与 `subagent/end.lastAssistantMessage` 都漏掉真实的部分回答。进程内驱动也没有流式文本兜底,因此被取消的子 agent 若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。
+当 `max-tokens` step 只组装出 tool-call block 时,agent loop 会追加空 content `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的 tool call;该 message 保留 stream 与 usage,但不贡献 output block。三个消费方独立选取 child agent 输出,并把该 record 当成输出。进程内 driver 的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK backend observer 则让任何 `assistant/message` 优先于累计 text。在被 max-tokens 截断的多 step turn 中,最后的空 message 导致 `SubagentResult.output`、tool result、telemetry 与 `subagent/end.lastAssistantMessage` 漏掉真实 partial answer。进程内 driver 也缺少 streamed-text fallback,因此被取消 child 的唯一 text 若只存在于嵌入式 Assistant stream 中,也会报告 `[]`。
## 决策
-`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。
+`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一规范选取规则:选取最后一条非空 Assistant message;没有时,从嵌入式 `assistant/message` 与 `assistant/attempt` stream 或 chunk-only transport 选取累计 `text-delta` content;忽略空 content message。增量 `AssistantOutputFold` 通过 `push(event)`、`pushText(text)` 与 `collect()` 实现该规则。`finalAssistantOutput(events)` 把规则应用于完整 event suffix,供进程内 `readResult` 与 Activation capture 使用。SDK backend 折叠 notification event;ACP backend 不公开完整 Assistant message,并折叠 raw chunk text。`SubagentResult.output` 定义 result contract,`subagent/end.lastAssistantMessage` 使用同一规则。child 不产生任一种输出时,一次性与 continuable run 的 lifecycle field 都缺省,而不是空 array。`max-tokens` 或 `aborted` result 保留实际 stop reason。
前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后呈现由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md)负责的可选安全提供方诊断,再附上子 agent 的部分文本。父模型会同时收到失败、独立的基础设施说明与已有 assistant 输出,而且不会把它们混为一体。
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
index 0c6fb58564..75a4fe2cbc 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
-2026-07-19-fresh-agent-ralph-workflow-tool.md: f374fcee7abc89c61c3ff6654093d1026cc8a715
-2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 1362e9ba528230b9247bb65e3bbfa2c61a72c568
+2026-07-19-fresh-agent-ralph-workflow-tool.md: 9eedc022f04f733feb5c4e5abb4b0528c0d10f60
+2026-07-19-fresh-agent-ralph-workflow-tool.zh.md: 23c614d5b5eadbe583469acff1efa01b398498db
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
index f374fcee7a..9eedc022f0 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.md
@@ -46,7 +46,7 @@ Human-facing presentation uses a generic `ralph` card whose raw input is the obj
Unit tests cover config and call-cap resolution, provider capability rejection, fixed start-request routing and child ceiling, all successful terminal outcomes, ordinary child-failure envelopes, malformed and oversized boundary values, exact successful-result truncation, abort timing, disposal, render intent, prompt lifecycle, and namespace-plugin shape at per-file 100% coverage. Worker-engine tests prove synchronous provider-route validation, per-run child ceilings below the deployment ceiling, and that a provider override selects every child without changing the configured default, including the built `lib/worker.cjs` under plain Node.
-A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, absent `seedLength`, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. The keyless [`ralph-loop` headless snapshot](../../../../snapshots/session/ralph-loop/) invokes `ralph`, pins the parent stream transcript, and inspects persisted logs for two distinct unseeded child sessions and the round-one handoff appearing only in round two. Tool tests pin generic call/result presentation, while request-header snapshots pin the shipped schema and prompt-guidance transcript output.
+A keyless real-stack integration drives the fixed script through the actual worker-thread engine, spawn provider, structured-output runtime, and agent loop. It proves distinct child identities, `isSeeded: false` with cut zero, inherited cwd, no parent-history markers in either child request, exact previous-report handoff only in the following round, one phase event, terminal completion, and disposal of both children. The same real stack covers blocker and round-limit outcomes, unnormalized and semantically invalid reports, oversized handoffs, ordinary child failure with the last good handoff, and cancellation to child quiescence. The keyless [`ralph-loop` headless snapshot](../../../../snapshots/session/ralph-loop/) invokes `ralph`, pins the parent stream transcript, and inspects persisted logs for two distinct unseeded child sessions and the round-one handoff appearing only in round two. Tool tests pin generic call/result presentation, while request-header snapshots pin the shipped schema and prompt-guidance transcript output.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
index 1362e9ba52..23c614d5b5 100644
--- a/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-19-fresh-agent-ralph-workflow-tool.zh.md
@@ -46,7 +46,7 @@ Ralph 插件的 `subagentProvider` 默认为 `spawn`。每次调用前,它要
单元测试覆盖配置与调用上限解析、提供方能力拒绝、固定启动请求路由与子 agent 上限、全部成功终止结果、普通子 agent 失败外层值、畸形及过大边界值、成功结果精确截断、中止时序、dispose(资源释放)、渲染意图、提示生命周期和命名空间插件形状,并达到逐文件 100% 覆盖率。工作流引擎测试证明提供方路由会同步验证、每次运行的子 agent 上限可低于部署上限,并且提供方覆盖会选择每个子 agent 且不改变配置默认值,其中包括普通 Node 下构建后的 `lib/worker.cjs`。
-一项无密钥真实栈集成测试通过实际工作线程引擎、spawn 提供方、结构化输出运行时和 agent loop 驱动固定脚本。它证明子 agent 标识不同、没有 `seedLength`、继承 cwd、两个子请求都不含父历史标记、上一份报告只精确出现在下一 Round 的交接中、只产生一个阶段事件、终止完成以及两个子 agent 都已 dispose。同一真实栈还覆盖阻塞与 Round 上限结果、未规范化及语义无效报告、过大交接、保留上一份有效交接的普通子 agent 失败,以及取消后子 agent 完全停稳。无密钥 [`ralph-loop` headless 快照](../../../../snapshots/session/ralph-loop/)会调用 `ralph`、固定父级流式 transcript,并检查持久化日志中存在两个不同且无种子的子会话,且 Round 1 的交接只出现在 Round 2。工具测试固定通用调用/结果展示,请求头快照固定发布的 schema 与提示指导 transcript 表面。
+一项无 key 真实栈 integration 通过实际 worker-thread engine、spawn provider、structured-output runtime 与 agent loop 驱动固定 script。它证明 child agent identity 不同、`isSeeded: false` 且 cut 为零、继承 cwd、两个 child request 都不含 parent-history marker、上一份 report 只精确出现在下一 Round handoff 中、只产生一个 phase event、terminal completion,以及两个 child agent 都已 dispose。同一真实栈还覆盖 blocker 与 Round-limit outcome、未规范化及语义无效 report、过大 handoff、保留上一份有效 handoff 的普通 child failure,以及取消后 child 完全停稳。无 key [`ralph-loop` headless snapshot](../../../../snapshots/session/ralph-loop/)调用 `ralph`、固定 parent stream transcript,并检查持久 log 中存在两个不同且 unseeded 的 child Session,且 Round 1 handoff 只出现在 Round 2。Tool test 固定通用 call/result presentation,request-header snapshot 固定已发布 schema 与 prompt-guidance transcript。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml
index 47eb54b338..2291fbad05 100644
--- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md
-2026-07-24-provider-retry-policies.md: 968f40272d3d3cb0efa362c97dcb8630888f80ec
-2026-07-24-provider-retry-policies.zh.md: 3274d0311bb79825ef9551549e4783a33c1cb6ee
+2026-07-24-provider-retry-policies.md: 996e7fcef2f323044761126d82dc53f73d3dde88
+2026-07-24-provider-retry-policies.zh.md: 8b3cb96ddb78f5bf0d680f8e9762622f1383286b
diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md
index 968f40272d..996e7fcef2 100644
--- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md
+++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md
@@ -40,7 +40,7 @@ Always mode asks downstream recovery first so a specialized policy such as conte
Both modes use exponential local delays from `initialDelayMs` to `maxDelayMs`. `jitterRatio` multiplies each target by a uniform sample in `[1 - jitterRatio, 1 + jitterRatio]`, then applies the cap. A positive provider `Retry-After` within the cap remains exact and unjittered. An over-cap provider delay makes normal mode delegate; always mode retains its guarantee by using the configured local backoff.
-Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, canonical resolved-policy key, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. The event and failed `assistant/chunk` records do not contribute surface messages, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface.
+Each scheduled retry appends a non-surface `llm/retry` event with the failed provider, policy mode, canonical resolved-policy key, provider-policy retry number, delay, and failure facts. Normal events carry finite `maxRetries`; always events omit it, and UIs render the limit as `∞`. Neither that event nor the failed attempt's `assistant/attempt` settlement contributes a surface message, so the next request contains the same derived context as the failed request unless another recovery policy deliberately changes the surface.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md
index 3274d0311b..8b3cb96ddb 100644
--- a/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.zh.md
@@ -40,7 +40,7 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之
两种模式的本地延迟都按指数增长,从 `initialDelayMs` 增至 `maxDelayMs`。`jitterRatio` 用 `[1 - jitterRatio, 1 + jitterRatio]` 区间内的均匀随机样本乘以每次目标值,再应用上限。提供方给出的正数 `Retry-After` 若未超过上限,则保持精确且不加抖动。若提供方延迟超过上限,normal 模式会委托后续处理;always 模式则改用配置的本地退避,以维持无限重试保证。
-每次安排重试都会追加一条不进入表层的 `llm/retry` 事件,其中包含失败的提供方、策略模式、已解析策略的规范键、提供方策略内的重试编号、延迟和失败事实。normal 事件包含有限的 `maxRetries`;always 事件省略该字段,UI 将上限渲染为 `∞`。该事件与失败的 `assistant/chunk` 记录都不会生成表层消息,因此除非其他恢复策略有意改变表层,否则下一次请求包含的派生上下文与失败请求相同。
+每次安排 retry 都会追加一条不进入 surface 的 `llm/retry` event,其中包含失败 provider、policy mode、resolved policy 的规范 key、provider-policy retry number、delay 与 failure facts。normal event 包含有限 `maxRetries`;always event 省略该字段,UI 把上限渲染为 `∞`。该 event 与失败 attempt 的 `assistant/attempt` settlement 都不产生 surface message,因此除非其他 recovery policy 有意改变 surface,否则下一次请求包含与失败请求相同的派生 context。
## 曾考虑的替代方案
diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml
index 4a864f9071..24a50b382d 100644
--- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
-2026-08-03-web-search-source-scroll.md: 6fe532e2a2989e834b926cf48d531ae60a32f58b
-2026-08-03-web-search-source-scroll.zh.md: 12bc6d3decb3c13ea759a9f25322b94e45db1322
+2026-08-03-web-search-source-scroll.md: 0609f45a198c5c9a35c2e2dd1f2ac8c3aaba7ccc
+2026-08-03-web-search-source-scroll.zh.md: 1649598dfc9198bdbbd36b82df6a4606af4c3e4d
diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
index 6fe532e2a2..0609f45a19 100644
--- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
+++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md
@@ -38,7 +38,7 @@ Every source the tool returned is always in the DOM, so no source the view carri
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `
` with no `[aria-expanded]` and no ``, every `` child is a source ``, and ` ` numbers 1..N contiguously. `packages/client/ui-tool/tests/web-card.client.spec.tsx` drops the `CHAT_WEB_MAX_SOURCES` cap assertion; the WebRow expansion test still asserts the card shows every source field. `packages/web/tool-web` independently pins the single- and multi-query model-side caps.
-jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 6 results for each of two queries, each with a title, a citation snippet, and a date. The real composition observes both provider requests and pins the tool's round-robin combined cap — the shipped `searchMaxResults` keeps 8 sources representing both queries, the model-visible render text omits the 4 dropped URLs and includes `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's ``: 8 ``, no `` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over a taller scroll body. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Replay is a positional cursor over the fixture's `assistant/chunk` entries and the search double is a separate local endpoint the provider reaches by `fetch`.
+jsdom resolves no CSS Modules layout, so it reports `scrollHeight === clientHeight` for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by `apps/web/tests/web-search-round.e2e.ts`: its deterministic search double returns 6 results for each of two queries, each with a title, a citation snippet, and a date. The real composition observes both provider requests and pins the tool's round-robin combined cap — the shipped `searchMaxResults` keeps 8 sources representing both queries, the model-visible render text omits the 4 dropped URLs and includes `(Showing the first 8 sources. Refine the query for more.)`, and `meta.truncated` is true. A case after the aria golden then expands the `web_search` row and asserts on the card's ``: 8 ``, no `` anywhere in the card, the `来源列表已截断` indicator visible, and computed `max-height: 320px` with `overflow-y: auto` over a taller scroll body. A further case measures a `999. ` marker in the list's own inherited font and requires the computed `padding-left` to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Replay is a positional cursor over the fixture's embedded Assistant settlements, and the search double is a separate local endpoint the provider reaches by `fetch`.
## Related
diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md
index 12bc6d3dec..1649598dfc 100644
--- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md
@@ -38,7 +38,7 @@ Status: implemented
`packages/client/ui-primitives/tests/web-block.client.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一张含 30 条来源的卡片渲染出全部 30 个 ``,无 `[aria-expanded]`、无 ``,每个 `` 子元素都是一条来源 ``,且 ` ` 从 1 到 N 连续编号。`packages/client/ui-tool/tests/web-card.client.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 独立固定单查询与多查询的模型侧上限。
-jsdom 不解析 CSS Modules 布局,对任何元素都报 `scrollHeight === clientHeight`,因此它根本无从见证这次滚动。几何改由组装态浏览器钉住,位于 `apps/web/tests/web-search-round.e2e.ts`:其确定性 search double 为两个查询分别返回 6 条结果,每条带标题、引用摘录与日期。真实组合会观察两次提供方请求,并固定工具的轮询组合上限——出厂 `searchMaxResults` 保留代表两个查询的 8 条来源,面向模型的 render 文本不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。随后位于 aria golden 之后的一个用例展开 `web_search` 行,对卡片的 `` 断言:8 个 ``、卡片内任何位置都没有 ``、`来源列表已截断` 指示可见,以及计算样式 `max-height: 320px` 与 `overflow-y: auto`,滚动主体高于容器。再后一个用例在列表自身继承的字体下量出 `999. ` 序号的宽度,要求计算后的 `padding-left` 不小于该宽度,从而把滚动容器无从滚回的那段序号空间钉在最宽序号上,而非钉在某一份 fixture(测试前置数据)的来源条数上。回放是对 fixture 中 `assistant/chunk` 条目的位置游标,而 search double 是提供方经 `fetch` 抵达的另一个本地端点。
+jsdom 不解析 CSS Modules layout,对任何 element 都报告 `scrollHeight === clientHeight`,因此无法见证滚动。几何由 assembled browser 的 `apps/web/tests/web-search-round.e2e.ts` 固定:确定性 search double 为两个 query 分别返回 6 条 result,每条带 title、citation snippet 与 date。真实 composition 观察两次 provider request,并固定 tool 的 round-robin combined cap——出厂 `searchMaxResults` 保留代表两个 query 的 8 条 source,model-visible render text 不含被丢弃的 4 条 URL,并含 `(Showing the first 8 sources. Refine the query for more.)`,`meta.truncated` 为 true。aria golden 后的 case 展开 `web_search` row,对 card 的 `` 断言:8 个 ``、card 内没有 ``、`来源列表已截断` indicator 可见,以及 computed style `max-height: 320px` 与 `overflow-y: auto`,scroll body 高于 container。后续 case 在 list 自身继承 font 下测量 `999. ` marker 宽度,要求 computed `padding-left` 不小于该宽度,从而把 scroll container 无法滚回的 marker space 固定在最宽 marker,而不是某个 fixture 的 source count。Replay 是对 fixture 嵌入式 Assistant settlement 的位置 cursor,search double 是 provider 通过 `fetch` 抵达的另一个 local endpoint。
## 相关文档
diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml
index 38ff1b42e2..2e2fee33d8 100644
--- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
-2026-08-10-web-session-log-export.md: df54c0b042de14c94e78d22e066bc61a1768c76f
-2026-08-10-web-session-log-export.zh.md: 4ed9d3839dece876a45a9af18ef3bc4609f8e3a9
+2026-08-10-web-session-log-export.md: 82731cbd8f8acde228bdc636448879678c8e8706
+2026-08-10-web-session-log-export.zh.md: 7088d4f2f556f930abb43f951203bbf3540aede5
diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
index df54c0b042..82731cbd8f 100644
--- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
+++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
@@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
## Decision
-- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a Session's **selected generation text verbatim**: `readRaw` on the persistence service reads the backend's numerically highest canonical generation after any required migration (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under the backend-reported logical basename. The root entry is `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation; descendants use `subagents//`. Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the selected durable generation and self-describing through its own header line.
+- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a Session's **selected generation text verbatim**: `readRaw` on the persistence service reads the backend's numerically highest canonical generation after any required migration (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so key order, line breaks, and any historical v0/v1 packed rows survive byte-for-byte — under the backend-reported logical basename. The root entry is `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation; descendants use `subagents//`. Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the selected durable generation and self-describing through its own header line.
- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the `/api` trust fence before dispatching the exact `GET`/`HEAD /api/session.export` route registered by `session-log-export`.
- **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation.
- The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md).
diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
index 4ed9d3839d..7088d4f2f5 100644
--- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
@@ -10,7 +10,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
## 决策
-- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是 Session **选定 generation 的逐字原文**:持久化服务的 `readRaw` 会在完成所需迁移后读取后端数值最高的规范 generation(JSONL 后端解码其物理 zstd frame,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、key 顺序与换行全部逐字节保留——并使用后端报告的逻辑 basename。根条目在 v0 下为 `session.jsonl`,正 generation 下为 `session.vN.jsonl`;后代使用 `subagents//`。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与 archive 大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个 archive 放进单个 buffer(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写 manifest:每个文件都与选定持久 generation 逐字节一致,并通过自身 header 行自描述。
+- **导出是 Host 侧 download,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP attachment。每个 file 都是 Session **选定 generation 的逐字原文**:persistence service 的 `readRaw` 会在完成所需 migration 后读取 backend 数值最高的规范 generation(JSONL backend 解码其物理 zstd frame,或直接返回 plaintext)——绝非从解析后 event 重建,因此 key 顺序、换行与任何历史 v0/v1 packed row 都逐字节保留——并使用 backend 报告的逻辑 basename。root entry 在 v0 下为 `session.jsonl`,正 generation 下为 `session.vN.jsonl`;descendant 使用 `subagents//`。压缩在 Host 侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已校验 `sessionExportCompressionLevel` 0–9(默认 6),使 deployment 可以在 CPU/latency 与 archive size 间取舍;每个 entry 按有界 block 边产出边 deflate,response 随生成分块写出,Host 从不把整个 archive 放进单个 buffer(除预载 root 外,最多同时持有一条 descendant artifact text)。到达 64 KiB response byte high-water mark 后,生产会等待 consumer pull 恢复容量;fflate 同步 callback 最多只会在该 queue bound 外再增加一次有界 input push。不写 manifest:每个 file 都与选定持久 generation 逐字节相同,并通过自身 header line 自描述。
- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。
- **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。
- 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。
diff --git a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.i18n.yaml
index 7e4bf365f9..b94de9b843 100644
--- a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md
-2026-08-21-headless-reasoning-progress.md: 6c4a3574b63ef316fd456f24b473406054ed30f3
-2026-08-21-headless-reasoning-progress.zh.md: e19fffc33fb5a55432ba2b6cb50550a0cfbd00b8
+2026-08-21-headless-reasoning-progress.md: 74868410839a497317c6a35097a20f4514b36dba
+2026-08-21-headless-reasoning-progress.zh.md: a03a380b7d5a79354d52a5172076918958c4a88a
diff --git a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md
index 6c4a3574b6..7486841083 100644
--- a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md
+++ b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md
@@ -6,27 +6,27 @@ English | [中文](2026-08-21-headless-reasoning-progress.zh.md)
## Problem
-The one-shot headless runner waits for complete Agent quiescence before printing the final assistant text. Reasoning-capable providers already expose their reasoning as durable `assistant/chunk` events, but a long reasoned response leaves the terminal silent until the run completes. The final answer must remain the only stdout payload so command substitution and other consumers keep a stable result channel.
+The one-shot headless runner waits for complete Agent quiescence before printing the final Assistant text. Reasoning-capable providers expose reasoning through live `agent/assistant-stream` chunk frames and the final durable settlement, but a long reasoned response leaves the terminal silent if the runner observes only settled history. The final answer must remain the only stdout payload so command substitution and other consumers keep a stable result channel.
The earlier [direct core entry-point decision](../architecture/2026-08-09-headless-direct-core-entry-point.md) required empty stderr on every successful run. That clause prevents live reasoning progress and is superseded by this note; its transport, durability, and completion decisions remain unchanged.
## Decision
-`headless-runner` observes the exact Session it creates after startup quiescence and before submitting the task. Once the owned interval opens with `turn/start`, each non-empty `assistant/chunk.reasoning-delta` is written immediately to stderr. A contiguous reasoning phase starts with `dsh: reasoning:` on its own line; deltas retain provider order without token-boundary decoration. Reasoning block boundaries and usage metadata keep that phase open; a later non-reasoning block or output delta, stream finish, new turn, or listener disposal terminates it with one newline when the provider supplied none.
+`headless-runner` observes `agent/assistant-stream` for the exact Agent it creates after startup quiescence and before submitting the task. Once the owned interval opens with `turn/start`, each non-empty `reasoning-delta` chunk frame is written immediately to stderr. A contiguous reasoning phase starts with `dsh: reasoning:` on its own line; deltas retain provider order without token-boundary decoration. Reasoning block boundaries and usage metadata keep that phase open; a later non-reasoning block or output delta, stream end, new turn, or listener disposal terminates it with one newline when the provider supplied none.
-This output is a transient projection of the existing durable Session event stream. The runner still derives final text and exit status from the flushed log rather than from progress-presentation state. The LLM adapter, agent loop, Session event types, persistence format, and SDK projections do not change.
+This output is a transient projection of the process-local Assistant stream. The runner still derives final text and exit status from the flushed durable Session settlements rather than from progress-presentation state. SDK projections do not expose the live frames.
Reasoning progress is not TTY-gated and has no separate flag. A redirected stderr stream and a supervisor receive the same provider-reported content as an attached terminal. A successful run without reasoning still writes nothing to stderr; terminal model and driver errors keep their existing `dsh:` diagnostics after any open reasoning phase is terminated.
## Verification
-The package test holds the Agent active after a reasoning delta and observes stderr before idle, then pins newline ownership for provider-terminated and unterminated phases plus terminal errors. The owner-local product expectation drives the shipped headless profile through a reasoning-plus-tool round and pins both stderr and the persisted Session. Recorded-session replay reconstructs expected stderr from scalar and packed chunk rows, closes sections on packed text and tool-call output, and uses the raw run log before fixture path tokenization in record modes. Built-bin acceptance sends `reasoning_content` through the native DeepSeek SSE adapter and requires reasoning on stderr while stdout remains the final answer.
+The package test holds the Agent active after a reasoning frame and observes stderr before idle, then pins newline ownership for provider-terminated and unterminated phases plus terminal errors. The owner-local product expectation drives the shipped headless profile through a reasoning-plus-tool round and pins both stderr and the persisted Session. Recorded-session replay reconstructs expected stderr by expanding embedded Assistant streams, closes sections on text and tool-call output, and uses the raw run log before fixture path tokenization in record modes. Built-bin acceptance sends `reasoning_content` through the native DeepSeek SSE adapter and requires reasoning on stderr while stdout remains the final answer.
## Alternatives considered
**Dump reasoning after quiescence.** Folding reasoning from the persisted log would preserve content but leave the terminal silent during the long-running interval that motivates the feature.
-**Wrap the LLM stream.** Tapping `ctx.llm.stream()` would place a presentation concern in the request path and duplicate the authoritative chunks that the agent loop already appends to the Session.
+**Wrap the LLM stream.** Tapping `ctx.llm.stream()` would place a presentation concern in the request path and duplicate the scoped frames that the agent loop already publishes.
**Print a spinner or periodic heartbeat.** A timer reports process liveness rather than provider progress, adds an interval policy, and still hides reasoning that the provider already supplies. Time before the first reasoning delta remains silent and can be addressed separately if providers buffer their first token.
diff --git a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md
index e19fffc33f..a03a380b7d 100644
--- a/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.zh.md
@@ -6,27 +6,27 @@ Status: implemented
## 问题
-一次性 headless runner 会等待 Agent(智能体)完全停稳,再打印最终 assistant 文本。具备推理能力的提供方已经把推理作为持久化的 `assistant/chunk` 事件暴露,但耗时较长的推理响应会让终端在运行完成前始终保持静默。最终答案必须继续作为 stdout 中唯一的载荷,使命令替换和其他消费方保持稳定的结果通道。
+一次性 headless runner 会等待 Agent(智能体)完全停稳,再打印最终 Assistant 文本。具备推理能力的 provider 会通过实时 `agent/assistant-stream` chunk frame 与最终持久 settlement 暴露 reasoning,但如果 runner 只观察已结算历史,耗时较长的 reasoning response 会让终端始终静默。最终答案必须继续作为 stdout 中唯一 payload,使命令替换和其他消费方保持稳定结果通道。
此前的[直接使用核心服务入口决策](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md)要求每次成功运行都保持 stderr 为空。该条款会阻止实时推理进度,因此由本 Agent Note 取代;其中关于传输、持久性与完成状态的其他决策保持不变。
## 决策
-`headless-runner` 在启动工作完全停稳后、提交任务前,观察其创建的精确 Session。自身持有的区间以 `turn/start` 打开后,每个非空的 `assistant/chunk.reasoning-delta` 都会立即写入 stderr。一段连续推理以独占一行的 `dsh: reasoning:` 开始;各分片保持提供方顺序,不添加 token 边界装饰。推理块边界与用量元数据会保持该段打开;之后出现非推理块或输出分片、流结束、新轮次或 listener dispose(资源释放)时,如果提供方没有输出末尾换行,runner 会用一个换行终止该段。
+`headless-runner` 在启动工作完全停稳后、提交任务前,为其创建的精确 Agent 观察 `agent/assistant-stream`。自身持有的区间以 `turn/start` 打开后,每个非空 `reasoning-delta` chunk frame 都会立即写入 stderr。一段连续 reasoning 以独占一行的 `dsh: reasoning:` 开始;各 delta 保持 provider 顺序,不添加 token 边界装饰。Reasoning block boundary 与 usage metadata 会保持该段打开;之后出现非 reasoning block 或 output delta、stream end、新 turn 或 listener dispose 时,如果 provider 没有输出末尾换行,runner 会用一个换行终止该段。
-该输出是既有持久化会话事件流的瞬时投影。runner 仍从 flush 后的日志而不是进度呈现状态推导最终文本与退出状态。LLM(大语言模型)适配器、agent loop(智能体循环)、Session 事件类型、持久化格式与 SDK 投影均不改变。
+该输出是进程本地 Assistant stream 的瞬态投影。runner 仍从 flush 后的持久 Session settlement 而非进度呈现状态推导最终文本与退出状态。SDK 投影不公开 live frame。
推理进度不按 TTY 启用,也没有单独 flag。重定向的 stderr 流与监督进程会收到和已连接终端相同的提供方报告内容。没有推理内容的成功运行仍不会写入 stderr;终止态模型错误与驱动器错误继续在任何已打开推理段终止后输出既有的 `dsh:` 诊断。
## 验证
-包测试在推理分片后保持 Agent 活跃,并在 idle 前观察 stderr;测试同时固定由提供方终止和未终止的推理段换行归属,以及终止态错误。产品自有期望通过包含推理与工具调用的轮次驱动随附 headless profile,并固定 stderr 与持久化 Session。录制会话回放从标量及压缩分片记录重建预期 stderr,在压缩文本或工具调用输出处关闭推理段,并在录制模式下于 fixture 路径标记化之前使用原始运行日志。构建后二进制验收通过原生 DeepSeek SSE(Server-Sent Events)适配器发送 `reasoning_content`,要求推理出现在 stderr,同时 stdout 仍只包含最终答案。
+包测试在 reasoning frame 后保持 Agent 活跃,并在 idle 前观察 stderr;测试同时固定由 provider 终止和未终止的 reasoning 段换行归属,以及 terminal error。产品自有期望通过包含 reasoning 与工具调用的 turn 驱动随附 headless profile,并固定 stderr 与持久 Session。录制 Session replay 通过展开嵌入式 Assistant stream 重建预期 stderr,在 text 与 tool-call output 处关闭 reasoning 段,并在 record mode 下于 fixture path tokenization 前使用原始 run log。构建后二进制 acceptance 通过原生 DeepSeek SSE adapter 发送 `reasoning_content`,要求 reasoning 出现在 stderr,同时 stdout 仍只包含最终答案。
## 考虑过的替代方案
**完全停稳后再输出推理。** 从持久化日志折叠推理能够保留内容,但在导致本功能产生的长时间运行区间内,终端仍会保持静默。
-**包装 LLM 流。** 截取 `ctx.llm.stream()` 会把呈现职责放入请求路径,并重复处理 agent loop 已经追加到 Session 的权威分片。
+**包装 LLM stream。** 截取 `ctx.llm.stream()` 会把呈现职责放入请求路径,并重复处理 agent loop 已发布的 scoped frame。
**打印 spinner 或周期性心跳。** 定时器报告的是进程存活状态,而不是提供方进度;它还会新增间隔策略,并继续隐藏提供方已经给出的推理。首个推理分片前的时间仍保持静默;如果提供方会缓冲首个 token,可以另行处理。
diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml
index c9f042554a..924b17d822 100644
--- a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md
-2026-08-06-buffer-free-feedback-telemetry.md: 387342c8282072d07957054fbb7e879a9874d23b
-2026-08-06-buffer-free-feedback-telemetry.zh.md: b0f5f4eadef35a6a3d59847d13029b98dae68f14
+2026-08-06-buffer-free-feedback-telemetry.md: d2b8c5908889bcea672d40e7a58cef373338c362
+2026-08-06-buffer-free-feedback-telemetry.zh.md: 4fc8ff5c69ef2b3f5f066359bbca61ec2719a627
diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md
index 387342c828..d2b8c59088 100644
--- a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md
+++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md
@@ -10,7 +10,7 @@ Feedback-only telemetry must upload the session-log prefix only after recorded f
## Decision
-The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no copied records. `captureSession(session, throughSeq?)` reads the canonical session log after the same-object handoff cursor through an optional inclusive sequence boundary, deep-copies every event in order, runs the current `session-telemetry/record` waterfall, and hands one record per event to the backend, including every `assistant/chunk` with its complete body. A new Session object has no WeakMap entry, so its logical cursor is `-1` and capture starts at seq 0.
+The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no copied records. `captureSession(session, throughSeq?)` reads the canonical session log after the same-object handoff cursor through an optional inclusive sequence boundary, deep-copies every event in order, runs the current `session-telemetry/record` waterfall, and hands one record per event to the backend, including each `assistant/message` or `assistant/attempt` with its complete embedded stream. A new Session object has no WeakMap entry, so its logical cursor is `-1` and capture starts at seq 0.
`FEEDBACK_ONLY` invokes that method with the `feedback/record` event's sequence. The append is already committed when `session/event` listeners run, so the replay contains the feedback event and cannot include a later suffix. The object-keyed handoff cursor distinguishes later replays without another pending-record index: repeated feedback on the same object releases only a suffix, while the first feedback on a new resumed or migrated object releases its complete current canonical prefix.
diff --git a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md
index b0f5f4eade..4fc8ff5c69 100644
--- a/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md
+++ b/.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md
@@ -10,7 +10,7 @@ Status: implemented
## 决策
-遥测协调器提供 `live` 与 `on-demand` 捕获。按需捕获不注册会话、flush 或运维事件监听器,也不保留记录副本。`captureSession(session, throughSeq?)` 从同一对象 handoff 游标之后读取权威会话日志,直至可选的包含式序列号边界,按顺序深拷贝每个事件、运行当前的 `session-telemetry/record` waterfall(瀑布式事件),并为每个事件向后端交接一条记录,其中包括每条 `assistant/chunk` 及其完整 body。新 Session 对象没有 WeakMap 条目,因此逻辑游标为 `-1`,捕获从 seq 0 开始。
+遥测 coordinator 提供 `live` 与 `on-demand` capture。按需 capture 不注册 Session、flush 或 operational-event listener,也不保留 record 副本。`captureSession(session, throughSeq?)` 从同一对象 handoff cursor 之后读取规范 Session log,直至可选的包含式序号 boundary,按序 deep-copy 每个 event、运行当前 `session-telemetry/record` waterfall,并为每个 event 向 backend 交接一条 record,其中包括每个带完整嵌入式 stream 的 `assistant/message` 或 `assistant/attempt`。新 Session 对象没有 WeakMap entry,因此逻辑 cursor 为 `-1`,capture 从 seq 0 开始。
`FEEDBACK_ONLY` 以 `feedback/record` 事件的序列号调用该方法。`session/event` 监听器运行时,追加已经提交,因此回放包含该反馈事件,且无法包含后续后缀。以对象为键的 handoff 游标可区分后续回放,无需另一个待处理记录索引:同一对象上的重复反馈只释放后缀,而新的 resume 或迁移对象上的首次反馈会释放其完整当前权威前缀。
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
index 1e0e66f927..a1f680adf8 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
-2026-06-19-acp-snapshot-tests.md: c69f4a66aedb72d1fb8b83c3ec05ec5bc069ebf9
-2026-06-19-acp-snapshot-tests.zh.md: 0cb64a95af0391a34a1903bdcaa0111e2195c9d7
+2026-06-19-acp-snapshot-tests.md: 49d6012a3bbe3a42daadd07030bca354f7897149
+2026-06-19-acp-snapshot-tests.zh.md: d4bf65ea425e5962edbf97acf70731d470be9800
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
index c69f4a66ae..49d6012a3b 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
@@ -18,13 +18,13 @@ The [session-log snapshot corpus decision](2026-08-24-session-log-snapshot-corpu
### The fixture projects the persisted session JSONL
-Each scenario's selected highest parent generation is harvested from a real run: `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary Session generation therefore serves as both replay source and behavioral expected output.
+Each scenario's selected highest parent generation is harvested from a real run: `session.jsonl` for v0 or `session.vN.jsonl` for a positive generation. The compact streams embedded in `assistant/message` and `assistant/attempt` reproduce model attempts; tool, message, and boundary events capture the harness behavior. One ordinary Session generation therefore serves as both replay source and behavioral expected output.
-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.
+Every current v2 session-format fixture uses one physical row per durable event. Retained v0 and v1 predecessor generations may contain their frozen packed-row representation and remain immutable. Ordinary replay and log comparison prove that the assembled process selects, migrates, consumes, and reproduces the current generation.
### Replay derives the model script from the log
-`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` splits recorded `assistant/chunk` events at terminal `finish` chunks and uses `(turn, step)` changes to reject an unterminated prior call. A `compaction/summary` with `llmStreamCall: true` contributes one call at its durable log position: replay reconstructs canonical block boundaries from `rawOutput`, retains recorded usage when present, and supplies a terminal `stop`. The marker distinguishes that local call from template or remote summaries whose retained `rawOutput` did not consume this context's adapter.
+`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` expands each recorded `assistant/message` or `assistant/attempt` stream into one positional call and validates its terminal chunk. A `compaction/summary` with `llmStreamCall: true` contributes one call at its durable log position: replay reconstructs canonical block boundaries from `rawOutput`, retains recorded usage when present, and supplies a terminal `stop`. The marker distinguishes that local call from template or remote summaries whose retained `rawOutput` did not consume this context's adapter.
### The in-memory replay entry honors the full LLM contract
@@ -36,7 +36,7 @@ Every committed session-format fixture uses the canonical packed physical layout
| { kind: 'hang' }
```
-Logs derive chunk entries from finished assistant streams and explicitly marked compaction calls. Pre-stream throws, hangs, and external summarizer calls have no reconstructable local chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons or provider output alone.
+Logs derive chunk or throw entries from durable Assistant settlements and explicitly marked compaction calls. Pre-stream throws, hangs, and external summarizer calls have no reconstructable local stream representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons or provider output alone.
### Positional replay, one in-flight stream
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
index 0cb64a95af..d4bf65ea42 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
@@ -18,13 +18,13 @@ Status: implemented
### fixture 投影持久化会话 JSONL
-每个场景数值最高的选定 parent generation 都从真实运行中采集:v0 为 `session.jsonl`,正 generation 为 `session.vN.jsonl`。`assistant/chunk` 事件复现模型 stream;工具、消息与边界事件捕获 harness 行为。因此,一份普通 Session generation 同时充当 replay source 与行为预期输出。
+每个场景数值最高的选定 parent generation 都从真实运行中采集:v0 为 `session.jsonl`,正 generation 为 `session.vN.jsonl`。`assistant/message` 与 `assistant/attempt` 中嵌入的紧凑 stream 会复现模型 attempt;工具、message 与 boundary event 捕获 harness 行为。因此,一份普通 Session generation 同时充当 replay source 与行为预期输出。
-每个签入仓库的会话格式 fixture 都使用规范的打包物理布局。覆盖所有行类型的场景从一份独立的真实录制机械派生;测试要求它包含每一种打包存储行类型,并在两份 fixture 解码后逐事件精确相等;随后,普通回放与日志比较会证明组装后的进程能够消费并复现该布局。
+每个当前 v2 Session-format fixture 都为每个持久事件使用一条物理行。保留的 v0 与 v1 predecessor generation 可以包含其冻结 packed-row 表示,并保持不可变。普通 replay 与 log 比较证明组装进程会选择、迁移、消费并复现当前 generation。
### 回放从日志推导模型脚本
-`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 在终止的 `finish` 分片处切分已记录的 `assistant/chunk` 事件,并用 `(turn, step)` 变化拒绝前一条未终止的调用。携带 `llmStreamCall: true` 的 `compaction/summary` 会在其持久日志位置贡献一次调用:回放根据 `rawOutput` 重建规范块边界,保留已记录的 usage(如有),并提供终止的 `stop`。该标记将这次本地调用与模板摘要或远程摘要区分开;后两者即使保留了 `rawOutput`,也未使用此上下文的适配器。
+`llm-replay` 短路 provider-neutral `llm/stream` waterfall。`deriveReplayScript()` 把每个已记录 `assistant/message` 或 `assistant/attempt` stream 展开为一次位置式调用,并校验其 terminal chunk。携带 `llmStreamCall: true` 的 `compaction/summary` 会在其持久 log 位置贡献一次调用:replay 根据 `rawOutput` 重建规范 block boundary,保留已记录 usage(如有),并提供 terminal `stop`。该 marker 将这次本地调用与 template 或 remote summary 区分开;后两者即使保留 `rawOutput`,也未使用此 context 的 adapter。
### 内存中的回放条目遵守完整的 LLM 约定
@@ -36,7 +36,7 @@ Status: implemented
| { kind: 'hang' }
```
-日志从已结束的 assistant 流和显式标记的压缩(compaction)调用推导分片条目。流开始前的抛出、挂起和外部摘要器调用没有可重建的本地分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因或单独的提供方输出推断适配器行为。
+Log 从持久 Assistant settlement 与显式标记的 compaction call 推导 chunk 或 throw entry。Stream 开始前的 throw、hang 与 external summarizer call 没有可重建的本地 stream 表示,因此这些场景提供 `replay.override.json`。throw entry 可以包含 prefix chunk 以模拟 stream 中途失败。显式 override 避免从有损 turn-end reason 或单独 provider output 推断 adapter 行为。
### 位置式回放,单个在途流
diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml
index 0032116546..6476b6a0fa 100644
--- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md
-2026-06-22-fork-child-replay-seed-boundary.md: 1fefc3546ebe029ba95318dad3ac2d463a3fe497
-2026-06-22-fork-child-replay-seed-boundary.zh.md: bb43eacf075318dc0101fca2cacb45a03d847764
+2026-06-22-fork-child-replay-seed-boundary.md: 3c1148f4c73e9d1f6a518dadb6f20509a6d97bff
+2026-06-22-fork-child-replay-seed-boundary.zh.md: f7646132a29d14dc858ef6d693a681b5d799e870
diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md
index 1fefc3546e..3c1148f4c7 100644
--- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md
+++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md
@@ -8,9 +8,9 @@ English | [中文](2026-06-22-fork-child-replay-seed-boundary.zh.md)
The [per-session snapshot replay Agent Note](2026-06-22-subagent-snapshot-replay.md) made the snapshot tier express a nested-agent shape: a parent plus one recorded log per in-process subagent, each replayed as its own script keyed by calling session. It noted (§ Scope, final bullet) that a fork snapshot was "a trivial future addition, not a gap in the keying." That was wrong about a fork child specifically — not the keying, but the *script derivation*.
-A subagent script is derived from a recorded session log by [`deriveReplayScript`](../../../../packages/test-support/llm-replay): it groups the log's `assistant/chunk` events by `(turn, step)` into one replay entry per `stream()` call. This is correct for a **spawn** child, whose log contains only its own model calls.
+A subagent script is derived from a recorded session log by [`deriveReplayScript`](../../../../packages/test-support/llm-replay): it expands each durable `assistant/message` or `assistant/attempt` settlement into one replay entry per `stream()` call. This is correct for a **spawn** child, whose log contains only its own model calls.
-A **fork** child is different. The fork backend seeds the child session with a *balanced completed-turn prefix of the parent's log* ([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver)), and that seed becomes the child session's persisted `log` (`Session`'s constructor copies the seed into `this.log`). So a fork child's `.jsonl` begins with the **parent's** events — including the parent's `assistant/chunk` events — and only then carries the child's own turn.
+A **fork** child is different. The fork backend seeds the child session with a *balanced completed-turn prefix of the parent's log* ([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver)), and that seed becomes the child session's persisted `log` (`Session`'s constructor copies the seed into `this.log`). A fork child's `.jsonl` therefore begins with the **parent's** events — including its Assistant settlements — and only then carries the child's own turn.
Deriving the child script from the whole fork-child log therefore replays the **parent's** recorded responses as the **child's** model calls: the live fork child's first `stream()` would receive the parent's first recorded chunk sequence instead of its own. All recorded scenarios use spawn, so this never fired — but a fork snapshot would have mis-routed silently, exactly the class of bug the snapshot tier exists to catch.
@@ -26,11 +26,11 @@ Record where a session's **inherited** prefix ends, persist it, and have the rep
### 2. JSONL round-trips it
-The v0 JSONL header keeps its optional numeric `seedLength` for byte compatibility. `toHeaderLine` / `fromHeaderLine` translate it to and from logical `isSeeded` plus the exact `inheritedEventCount`, which the shared body-bearing persistence values return separately.
+The v2 JSONL header carries `isSeeded`, while `session/end-seed { inherited: true }` marks the exact cut in the body; decoding uses the last tagged marker. The frozen v0 header keeps optional numeric `seedLength` for byte compatibility, and its codec translates that historical field into the same logical pair.
### 3. Replay derives a child script after the boundary
-`dsh-llm-replay`'s private v0 parser reads physical `seedLength` into `inheritedEventCount` (absent ⇒ 0), and `loadSessionScripts` derives a child's entries from `parseSessionLog(text).slice(inheritedEventCount)` — the events at or after the boundary, i.e. the child's own model calls. For a spawn child the cut is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged.
+`dsh-llm-replay` parses the selected generation through the static format catalog and retains its decoded `inheritedEventCount`. `loadSessionScripts` derives a child's entries from `fixture.events.slice(inheritedEventCount)` — the events at or after the boundary, which are the child's own model calls. For a spawn child the cut is 0 and this is a no-op.
This closes the routing correctness gap, and two recorded fork scenarios exercise it end to end — see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md).
diff --git a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md
index bb43eacf07..f7646132a2 100644
--- a/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md
+++ b/.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md
@@ -8,9 +8,9 @@ Status: implemented
[逐会话快照回放 Agent Note](2026-06-22-subagent-snapshot-replay.zh.md)使快照层能够表达嵌套 agent(智能体)形状:一个父项加上每个进程内 subagent 的一份记录日志,每份日志都按调用会话作为键,以独立脚本回放。它曾指出(§ 范围,最后一个项目符号),fork 快照「只是未来很容易添加的一项,并非键控缺口」。这一判断对 fork 子会话而言是错误的——问题不在键控,而在*脚本派生*。
-subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm-replay) 从已录制的会话日志推导:它按 `(turn, step)` 对日志中的 `assistant/chunk` 事件分组,每次 `stream()` 调用对应一条回放条目。对 **spawn** 子会话而言这是正确的,因为其日志只包含自身的模型调用。
+subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm-replay) 从已录制 Session log 推导:它把每个持久 `assistant/message` 或 `assistant/attempt` settlement 展开为每次 `stream()` 调用对应的一条 replay entry。对 **spawn** 子 Session 而言这是正确的,因为其 log 只包含自身模型调用。
-**fork** 子会话不同。fork 后端用*父日志的一段平衡的已完成轮次前缀*([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver))来播种子会话,而该 seed 会成为子会话持久化的 `log`(`Session` 构造函数将 seed 复制进 `this.log`)。因此 fork 子会话的 `.jsonl` 以**父会话**的事件开头——包括父会话的 `assistant/chunk` 事件——之后才是子会话自身的轮次。
+**fork** 子 Session 不同。fork backend 用*父 log 的一段平衡已完成 turn 前缀*([`dsh-subagent-in-process-driver`](../../../../packages/subagent/subagent-in-process-driver))播种子 Session,该 seed 会成为子 Session 持久化的 `log`(`Session` 构造函数把 seed 复制进 `this.log`)。因此 fork 子 Session 的 `.jsonl` 以**父 Session** event 开头——包括其 Assistant settlement——之后才是子 Session 自己的 turn。
从 fork 子会话的完整日志推导脚本,会把**父会话**的已录制响应当作**子会话**的模型调用来回放:实际运行的 fork 子会话第一次调用 `stream()` 时,会收到父会话的第一段分片序列而非自身的。所有已录制场景都使用 spawn,所以这从未触发——但 fork 快照会静默地错误路由,恰好属于快照层存在的意义所要捕获的那类 bug。
@@ -26,11 +26,11 @@ subagent 脚本由 [`deriveReplayScript`](../../../../packages/test-support/llm-
### 2. JSONL 完整往返
-v0 JSONL header 为保持字节兼容而继续携带可选数值 `seedLength`。`toHeaderLine`/`fromHeaderLine` 在它与 logical `isSeeded` 加精确 `inheritedEventCount` 之间转换,共享的含正文持久化值再单独返回该 cut。
+v2 JSONL header 携带 `isSeeded`,而 `session/end-seed { inherited: true }` 会在正文中标记精确 cut;解码使用最后一个 tagged marker。冻结的 v0 header 为保持字节兼容而继续携带可选数值 `seedLength`,其 codec 会把该历史字段转换为相同的逻辑值对。
### 3. 回放从边界之后推导子会话脚本
-`dsh-llm-replay` 的私有 v0 parser 把物理 `seedLength` 读入 `inheritedEventCount`(缺失则为 0),`loadSessionScripts` 从 `parseSessionLog(text).slice(inheritedEventCount)` 推导子会话条目——即边界及之后的事件,也就是子会话自身的模型调用。对 spawn 子会话而言 cut 为 0,此操作是空操作,spawn 场景逐字节不变。
+`dsh-llm-replay` 通过静态格式 catalog 解析选定 generation,并保留解码后的 `inheritedEventCount`。`loadSessionScripts` 从 `fixture.events.slice(inheritedEventCount)` 推导子 Session entry——即 boundary 及之后的 event,也就是子 Session 自己的模型调用。对 spawn 子 Session 而言 cut 为 0,因此此操作为空操作。
这弥补了路由正确性的缺口,两个已录制的 fork 场景对其进行端到端验证——见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)。
diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml
index 04ea7b6f32..f3914e1245 100644
--- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
-2026-06-22-subagent-snapshot-replay.md: e3da6ed984e2fc446556ee9f30d7ab9a2eef82ce
-2026-06-22-subagent-snapshot-replay.zh.md: ee2271d5fc91cbf78c744a474764c1566ab5dc3b
+2026-06-22-subagent-snapshot-replay.md: 2cca3206363a5211f598563085bb0165025d1313
+2026-06-22-subagent-snapshot-replay.zh.md: c6edfa7fa7faba259da0c593cdcc0cb507e0ea0c
diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
index e3da6ed984..2cca320636 100644
--- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
+++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.md
@@ -54,5 +54,5 @@ Both replay keyless in the default gate.
- The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape.
- `GenerateOptions.sessionId` is a small, honest core API addition useful beyond replay (telemetry, request routing).
-- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md) — and recorded fork + mixed spawn+fork scenarios now exercise both transports through one transcript (see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)).
+- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* needed the fork cut because a fork child's log begins with the seeded parent prefix, including the parent's Assistant settlements; deriving from the whole log would replay the parent's responses as the child's. The persisted seed boundary closes that gap — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md) — and recorded fork + mixed spawn+fork scenarios exercise both transports through one transcript (see [Record fork and mixed spawn+fork snapshot scenarios](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md)).
- Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in `subagent-acp`.
diff --git a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md
index ee2271d5fc..c6edfa7fa7 100644
--- a/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md
+++ b/.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md
@@ -54,5 +54,5 @@ Status: implemented
- `TODO(subagent-snapshots)` 延期项已解决:嵌套 agent 的 transcript 现在是快照层的一等形态。
- `GenerateOptions.sessionId` 是一个小而诚实的 core API 新增,在回放之外同样有用(遥测、请求路由)。
-- `subagent` 工具绑定到单一提供方,因此 `subagent-multi` 中的两个子 agent 都是 spawn(全新创建)。键控按会话路由而非按后端路由,因此对 fork 同样正确。但脚本*派生*逻辑此前不正确:fork 子会话的日志以种子化的父前缀(父会话的 `assistant/chunk` 事件)开头,如果从完整日志派生脚本,就会把父 agent 的响应当作子 agent 的来回放。这一正确性缺口通过持久化种子边界来弥合——见[持久化 seed 边界以确保 fork 子会话回放正确路由](2026-06-22-fork-child-replay-seed-boundary.zh.md)——录制的 fork 与混合 spawn+fork 场景现在通过一份 transcript 同时验证两种传输方式(见[记录 fork 与混合 spawn+fork 快照场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md))。
+- `subagent` 工具绑定到单一 provider,因此 `subagent-multi` 中两个 child 都是 spawn。键控按 Session 而非 backend 路由,因此对 fork 同样正确。脚本*派生*需要 fork cut,因为 fork 子 Session log 以 seeded parent prefix 开头,其中包含 parent Assistant settlement;从完整 log 派生会把 parent response 当作 child response replay。持久 seed boundary 会关闭该缺口——见[持久化 seed 边界以确保 fork 子 Session replay 正确路由](2026-06-22-fork-child-replay-seed-boundary.zh.md)——录制的 fork 与混合 spawn+fork 场景通过一份 transcript 验证两种 transport(见[记录 fork 与混合 spawn+fork snapshot 场景](../../archived/testing/2026-06-22-fork-snapshot-scenarios.md))。
- 进程外(ACP(Agent Client Protocol))subagent 是完全不同的回放形态(每个子 agent 是自己的进程、有自己的回放),作为 `TODO(acp-subagent-replay)` 记录在 `subagent-acp` 中。
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
index 1605f1d168..c4dcff9e57 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: 6a73cced5e8f085ad5c6a7b9b280ce93ce2d1cf7
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 7eb6a2e1facc1b38ca153eee3c649fbd66a7134e
+2026-07-24-web-gui-browser-e2e-lane.md: 5a88c49b0811e80c19e3a39cf5604a74460fc3e2
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 829b07b789c2cfe74bcaf3b9db03acffd156887b
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
index 6a73cced5e..5a88c49b08 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
@@ -26,7 +26,7 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f
The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state.
-No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
+No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
index 7eb6a2e1fa..829b07b789 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
@@ -26,7 +26,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);禁止轮询持久化文件来充当轮次完成或持久性屏障(NFS 上慢,且被 `whenIdle` 取代),但工具控制的临时就绪标记可以仅作为该完成屏障之前的交互门控进行轮询;`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。导航断言会在页面加载前同时监听 `session.list` 和 `workspace.list` 的初始响应,随后等待播种数据投影到 DOM;仅凭 shell 已挂载不能判定就绪,因为较晚完成的 bootstrap 可能替换受控状态。
-不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。
+不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml
index 5f5facd8d7..c345ae674c 100644
--- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
-2026-08-03-opt-in-reasoning-chunk-browser-stress.md: 70c200c7ade6ef995c68b68ddc21e4c85edf0da8
-2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 9fb4be27369d4489fc90a6151fcbd0861ef83f2c
+2026-08-03-opt-in-reasoning-chunk-browser-stress.md: bc5d31d80e5fb8aebcd9988dcb1f051d113cbf63
+2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md: 105c8d69c37a18fd18aed3cc9f6f716e38b952d7
diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
index 70c200c7ad..bc5d31d80e 100644
--- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
+++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.md
@@ -6,15 +6,15 @@ English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md)
## Problem
-Long reasoning streams continuously produce large numbers of `assistant/chunk` events. Each raw event must be ordered, logged, and folded into `PartialAccumulator` to preserve replay fidelity and the completeness of the final content; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
+Long reasoning streams continuously produce large numbers of process-local `assistant/live-chunk` updates before one durable settlement. Each update must remain ordered and be folded into the Assistant Definition to preserve live completeness, while the settlement embeds the exact stream for replay; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events.
## Decision
-`Session.acceptLiveEvent()` appends every raw event immediately and synchronously updates the transcript, `PartialAccumulator`, and other session-derived state. Visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks publish through `Notifier.markFrameDirty()`: the first change schedules one `requestAnimationFrame`, later chunks only continue updating the accumulator, and the frame callback rebuilds one accumulated snapshot from the latest state and notifies subscribers once. `usage`, `finish`, and unknown invisible chunks remain in the event window but trigger no redundant React notifications. Session and history checks share the same visible-chunk classification.
+The Session Controller appends every Client-only live chunk to its event source, and Conversation immediately folds it into each matching Definition State. Chat and Trajectory Definitions request `animation-frame` publication for visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks; the first change schedules one `requestAnimationFrame`, later chunks continue updating State, and the frame callback materializes one accumulated snapshot from the latest State. `usage` and `finish` request no publication. The durable `assistant/message` or `assistant/attempt` settlement publishes immediately and reproduces the same final stream during history replay.
-`Notifier` tracks pending publication work with a scheduling kind and generation marker. Ordinary structural events continue to publish in a microtask through `markDirty()`; if a finalized message, tool event, or error arrives while a frame publication is pending, the microtask supersedes it and the old frame callback is invalidated by its generation mismatch. `notifyNow()` likewise invalidates the old schedule to preserve synchronous echo for controlled inputs. Environments without `requestAnimationFrame` fall back to microtask batching. A finalization event may skip one intermediate partial that has not yet appeared, while the published final content and raw event sequence remain complete.
+`BoundConversation` owns one pending frame per Session. Ordinary structural events and durable settlements request immediate publication, flush the latest assembled State, and make a later frame callback harmless because no dirty Context remains. Environments without `requestAnimationFrame` publish immediately. A settlement may skip one intermediate partial that has not yet appeared, while the published final content and durable embedded stream remain complete.
Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
@@ -26,7 +26,7 @@ Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption
**React transitions, deferred values, or component throttling applied to snapshots.** Rejected: the session source would still notify `useSyncExternalStore` for every chunk, the React render has already occurred before a component decides to defer display, and multiple components consuming the same snapshot would each need to implement the strategy. Visual tail-following throttling for the Think summary occurs after snapshot publication and only reduces the frequency of synchronous layout; it does not implement the data-publication policy.
-**Dropping, sampling, or concatenating raw chunks at the ingestion or logging layer.** Rejected: raw `assistant/chunk` events are replayable session facts; changing them would reduce diagnostic and UI fidelity and mix display-frequency policy into the authoritative data layer.
+**Dropping or sampling live chunks before Definition folding.** Rejected: the live accumulated state would diverge from the durable embedded stream and could omit visible intermediate content. Compact durable storage and frame-coalesced React publication solve different costs.
**Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk.
diff --git a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md
index 9fb4be2736..105c8d69c3 100644
--- a/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md
+++ b/.agents/notes/implemented/testing/2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md
@@ -6,15 +6,15 @@ Status: implemented
## 问题
-长推理流会连续产生大量 `assistant/chunk`。这些原始事件必须逐个完成排序、日志记录和 `PartialAccumulator` 折叠,以保持重放保真度和最终内容完整;但 React 只需要看到当前累计结果,不需要观察同一浏览器帧内的每个中间态。
+长 reasoning stream 会在一个持久 settlement 前连续产生大量进程本地 `assistant/live-chunk` update。每个 update 都必须保持有序并折叠进 Assistant Definition,以保留实时完整性;settlement 则嵌入精确 stream 供 replay。React 只需要看到当前累计结果,不需要观察同一浏览器帧内每个中间态。
异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题。
## 决策
-`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript(文本记录)、`PartialAccumulator` 及其他会话派生状态。可见的 `block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta` 和 `block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage`、`finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类。
+Session Controller 把每个 Client-only live chunk 追加到 event source,Conversation 会立即把它折叠进每个匹配 Definition State。Chat 与 Trajectory Definition 为可见 `block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta` 与 `block-end` chunk 请求 `animation-frame` publication;第一项变化调度一次 `requestAnimationFrame`,后续 chunk 继续更新 State,frame callback 再从最新 State materialize 一个累计 snapshot。`usage` 与 `finish` 不请求 publication。持久 `assistant/message` 或 `assistant/attempt` settlement 会立即发布,并在历史 replay 中复现同一最终 stream。
-`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务中发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial,但发布的定稿内容和原始事件序列保持完整。
+`BoundConversation` 为每个 Session 拥有一个 pending frame。普通结构 event 与持久 settlement 请求 immediate publication,flush 最新 assembled State,并让之后的 frame callback 因没有 dirty Context 而不产生影响。没有 `requestAnimationFrame` 的环境会立即发布。Settlement 可以跳过一个尚未显示的中间 partial,但发布的最终 content 与持久嵌入式 stream 保持完整。
实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth` 和 `clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`。
@@ -26,7 +26,7 @@ Status: implemented
**在 React 内对快照使用 transition、deferred value 或组件节流。** 不予采纳:会话源仍会逐分片通知 `useSyncExternalStore`,React render 在组件决定延后展示之前已经发生,且多个消费同一快照的组件需要重复实现策略。Think 摘要的视觉跟尾节流位于快照发布之后,只减少同步布局频率,不承担数据发布策略。
-**在接收或日志层丢弃、抽样或拼接原始分片。** 不予采纳:原始 `assistant/chunk` 是可重放的会话事实,改变它会损失诊断与 UI 保真度,并把展示频率策略混入数据权威层。
+**在 Definition fold 前丢弃或抽样 live chunk。** 不予采纳:实时累计 State 会与持久嵌入式 stream 分歧,并可能省略可见中间内容。紧凑持久存储与逐 frame 合并 React publication 解决的是不同成本。
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使微任务合批近似退化为每个分片通知一次。
diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml
index c40255128f..e7ec1f313a 100644
--- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml
+++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md
-2026-07-08-interactive-side-sessions.md: 653f77b931fe1149aad9e15b82f40fe3272f5469
-2026-07-08-interactive-side-sessions.zh.md: 55300fbad297b27e18a9e0213d4f45bc7fc08070
+2026-07-08-interactive-side-sessions.md: d1867b7088d89e20683f54300728e19d9ae9e5e5
+2026-07-08-interactive-side-sessions.zh.md: bd2772e1d44eb7e5cc324b2e4600fe6c1bc9169f
diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md
index 653f77b931..d1867b7088 100644
--- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md
+++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.md
@@ -12,7 +12,7 @@ A user may want to explore a question from a live session without changing its m
A **side session** is an ordinary live session forked at the source's last completed turn, attached to its own agent, framed as a read-only advisor, and able to **merge back** one condensed note.
-- **Fork and attach:** create the child with the parent's balanced completed-turn prefix and stamp `parentSession` and `seedLength` in its metadata. This composes `ctx.agents.create({ seed, meta })`; it adds no core service or session-store method.
+- **Fork and attach:** create the child with the parent's balanced completed-turn prefix, `parentSession`, `meta.isSeeded: true`, and the exact sibling `inheritedEventCount`. This composes `ctx.agents.create({ seed, inheritedEventCount, meta })`; it adds no core service or session-store method.
- **Advisor framing:** inject one plugin-sourced `context/message` after creation that tells the child to explain without mutating or continuing the task. Keeping the system prompt byte-identical preserves the provider prefix cache over inherited history.
- **Merge-back:** ask the child for a length-capped handback, then inject one plugin-sourced `context/message` into the parent. The next parent request sees it at its logged position, preserving replay and [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) without a new session event.
- **Presentation:** invocation, session switching, and handback rendering belong to the first client UI. This Agent Note specifies only the client-independent mechanics.
@@ -28,7 +28,7 @@ Rewind productization, session-tree views, a model-facing side-session tool, and
## Acceptance criteria
-- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `seedLength`, and a byte-identical system prompt.
+- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `isSeeded: true`, the exact `inheritedEventCount`, and a byte-identical system prompt.
- Advisor framing adds exactly one plugin-sourced `context/message` at the head of the child's appended history, rather than changing its system prompt.
- Merge-back adds exactly one length-capped `context/message` with source `plugin: sidechat`; the next parent request and replay see it at the same position.
- Parent and child run concurrently without log or stream cross-talk.
diff --git a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md
index 55300fbad2..bd2772e1d4 100644
--- a/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md
+++ b/.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md
@@ -12,7 +12,7 @@ Status: proposed
**侧会话(side session)**是一个普通的活跃会话,从源会话的最后一个已完成轮次 fork 而来,绑定到自己的 agent(智能体),定位为只读顾问,并能**合并回写**一条精简笔记。
-- **Fork 并绑定:**以父会话的平衡已完成轮次前缀创建子会话,并在其元数据中标记 `parentSession` 与 `seedLength`。这组合了 `ctx.agents.create({ seed, meta })`;不新增核心服务或会话存储方法。
+- **Fork 并绑定:**用 parent 的平衡 completed-turn prefix、`parentSession`、`meta.isSeeded: true` 与精确 sibling `inheritedEventCount` 创建 child。这组合 `ctx.agents.create({ seed, inheritedEventCount, meta })`;不新增 core service 或 Session-store method。
- **顾问定位:**创建后注入一条插件来源的 `context/message`,告知子会话只做解释,不执行变更或继续任务。保持系统提示词逐字节一致,可在继承的历史上保留提供方的前缀缓存。
- **合并回写:**向子会话请求一条有长度上限的 handback,然后向父会话注入一条插件来源的 `context/message`。父会话的下一次请求在日志所记录的位置看到该消息,保持回放与[请求可重建性](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md),无需新增会话事件。
- **呈现:**调用方式、会话切换与 handback 渲染属于首个客户端拥有的界面。本 Agent Note 仅规定与界面无关的机制。
@@ -28,7 +28,7 @@ Status: proposed
## 验收标准
-- Fork 不改变源会话,创建的子会话具有平衡的已完成轮次前缀、`parentSession`、`seedLength`,以及逐字节一致的系统提示词。
+- Fork 不改变 source Session,创建的 child 带有平衡 completed-turn prefix、`parentSession`、`isSeeded: true`、精确 `inheritedEventCount` 与逐字节相同 system prompt。
- 顾问定位在子会话追加历史的头部恰好添加一条插件来源的 `context/message`,而非修改其系统提示词。
- 合并回写恰好添加一条有长度上限的 `context/message`,来源为 `plugin: sidechat`;父会话的下一次请求与回放在相同位置看到它。
- 父会话与子会话并发运行,日志和流之间无串扰。
diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml
deleted file mode 100644
index f0bfe6cda5..0000000000
--- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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 .agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md
-2026-06-20-assembled-assistant-messages-only.md: edc756f71271065ec579a2becb8597190b34cdc8
-2026-06-20-assembled-assistant-messages-only.zh.md: 98b833a5d0cbca5f2295076e2ef50f450c9b93be
diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md
deleted file mode 100644
index edc756f712..0000000000
--- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Agent Note: Persist assembled assistant messages, not stream chunks
-
-Status: rejected — high-fidelity chunk replay, partial failed streams, and snapshot replay currently depend on persisted `assistant/chunk` events. Dropping chunks is only viable with a no-information-loss replay/artifact replacement.
-
-English | [中文](2026-06-20-assembled-assistant-messages-only.zh.md)
-
-## Problem
-
-The canonical session log currently persists every `assistant/chunk` exactly as streamed by the model. The [session persistence Agent Note](../../implemented/architecture/2026-06-14-session-persistence.md) chose this for token-level replay fidelity and contiguous `seq`, but the cost has grown: JSONL fixtures are dominated by tiny delta records, snapshot scenarios replay the model by grouping chunk events, ACP load reconstructs prior assistant output from chunks, and any future log reader must distinguish durable message history from token-level trace.
-
-For successful steps that assemble completed content, the loop already appends an `assistant/message`. That is the event `deriveMessages()` uses for the next model request. In other words, the normal resumable conversation state is already present without the chunks; chunks are a live rendering and deterministic-test artifact, not required conversation history. Failed or aborted streams are different: partial assistant output may exist only as chunks, and empty max-token steps may produce no `assistant/message` at all.
-
-## Proposal
-
-Stop storing `assistant/chunk` in the canonical session log. The durable log keeps `assistant/message`, `tool/call`, `tool/result`, `usage` if retained, and turn boundaries. Live UIs can still receive token deltas through a deliberately transient stream event. Snapshot replay should move its model script into an explicit fixture sidecar or derive it from a recorded adapter artifact, rather than treating the canonical user session as a token tape. Scenarios that need partial failed-stream output must record that output in the replay fixture.
-
-ACP `session/load` can replay prior assistant messages as complete content blocks instead of simulating the original token stream. A loaded transcript need not reproduce every historical delta; it must show the same completed assistant content and resume with a valid provider history.
-
-## Acceptance criteria
-
-- `SessionEventMap` drops `assistant/chunk`, or marks it as non-persisted if a transitional live event is needed.
-- [Session persistence docs](../../../../packages/session/session-persistence/README.md) no longer require every stream chunk to be stored verbatim.
-- `llm-replay` and ACP snapshots use an explicit replay fixture format or sidecar for model chunks.
-- `session/load` renders completed assistant messages from `assistant/message`.
-- Stored logs get much smaller and remain `seq`-contiguous without chunk holes.
-- The session format version and recorded fixtures are refreshed; non-current stored logs are rejected per the pre-release format policy.
-
-## What we give up
-
-The canonical user session no longer reconstructs the exact token stream of an old turn. It also loses partial assistant output from failed or aborted streams unless another event or fixture records it. That is too much information loss for the current resume, load, and snapshot contracts. Tests that need exact deterministic streams should own that fixture directly only if the production session log keeps enough fidelity for user-visible recovery.
-
-## Related
-
-This supersedes the chunk-persistence choice in [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) and affects [ACP snapshot tests](../../implemented/testing/2026-06-19-acp-snapshot-tests.md), whose current replay plugin derives its script from `assistant/chunk` events.
-
-
diff --git a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md b/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md
deleted file mode 100644
index 98b833a5d0..0000000000
--- a/.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Agent Note: 仅持久化组装后的 assistant 消息,不存储流式分片
-
-Status: rejected — 高保真分片回放、失败流的部分输出与快照回放目前依赖持久化的 `assistant/chunk` 事件。只有具备无信息损失的回放或产物替代方案后,才能删除分片。
-
-[English](2026-06-20-assembled-assistant-messages-only.md) | 中文
-
-## 问题
-
-当前的规范会话日志会持久化模型流式输出的每一个 `assistant/chunk`。[会话持久化 Agent Note](../../implemented/architecture/2026-06-14-session-persistence.zh.md)选择这一方案是为了 token 级回放保真度和连续的 `seq`,但其代价日益增长:JSONL fixture(测试前置数据)被大量微小的增量记录占据,快照场景通过对分片事件分组来回放模型,ACP(Agent Client Protocol)加载时从分片重建先前的 assistant 输出,而任何未来的日志读取方都必须区分持久的消息历史与 token 级追踪。
-
-对于成功组装出完整内容的步骤,agent loop(智能体循环)已经追加了一条 `assistant/message`。这正是 `deriveMessages()` 用来构造下一次模型请求的事件。换言之,正常的可恢复会话状态无需分片即已具备;分片是实时渲染和确定性测试的产物,不是必需的会话历史。失败或中止的流则不同:部分 assistant 输出可能仅以分片形式存在,而空的 max-token 步骤可能根本不产生 `assistant/message`。
-
-## 提案
-
-停止在规范会话日志中存储 `assistant/chunk`。持久日志保留 `assistant/message`、`tool/call`、`tool/result`、`usage`(如保留)以及轮次边界。实时 UI 仍可通过一个明确设计为瞬态的流事件接收 token 增量。快照回放应将其模型脚本移入显式的 fixture 伴随文件,或从记录的适配器产物中派生,而非将规范的用户会话当作 token 磁带。需要失败流部分输出的场景必须在回放 fixture 中记录该输出。
-
-ACP `session/load` 可以将先前的 assistant 消息作为完整内容块回放,而非模拟原始的 token 流。加载后的 transcript(文本记录)无需重现每一个历史 delta;它必须展示相同的已完成 assistant 内容,并基于有效的提供方历史继续运行。
-
-## 验收标准
-
-- `SessionEventMap` 移除 `assistant/chunk`,或在需要过渡性实时事件时将其标记为非持久化。
-- [会话持久化文档](../../../../packages/session/session-persistence/README.zh.md)不再要求逐字存储每个流式分片。
-- `llm-replay` 和 ACP 快照使用显式的回放 fixture 格式或伴随文件来存储模型分片。
-- `session/load` 从 `assistant/message` 渲染已完成的 assistant 消息。
-- 存储的日志大幅缩小,且删除分片后仍保持 `seq` 连续,不留下序号缺口。
-- 会话格式版本与已记录的 fixture 一并刷新;按预发布格式策略拒绝非当前版本的存储日志。
-
-## 放弃了什么
-
-规范的用户会话不再能重建旧轮次的精确 token 流。它也会丢失失败或中止流的部分 assistant 输出,除非另有事件或 fixture 记录。对于当前的恢复、加载和快照约定而言,这是过大的信息损失。需要精确确定性流的测试应当直接拥有该 fixture,前提是生产会话日志为用户可见的恢复保留了足够的保真度。
-
-## 相关
-
-本 Agent Note 取代 [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.zh.md) 中关于分片持久化的决策,并影响 [ACP 快照测试](../../implemented/testing/2026-06-19-acp-snapshot-tests.zh.md)——其当前的回放插件从 `assistant/chunk` 事件派生脚本。
-
-
diff --git a/apps/cli/tests/github-webhook-real.e2e.ts b/apps/cli/tests/github-webhook-real.e2e.ts
index ecec861d30..f6583c79fa 100644
--- a/apps/cli/tests/github-webhook-real.e2e.ts
+++ b/apps/cli/tests/github-webhook-real.e2e.ts
@@ -11,7 +11,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
-import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows'
import { describe, expect, it } from 'vitest'
import WebSocket from 'ws'
@@ -60,10 +59,7 @@ interface WorkspaceBaseline {
}
interface HistoryPage {
- records: Array<
- | { type: 'event'; event: HistoryEvent }
- | { type: 'chunks'; event: HistoryChunkEvent }
- >
+ records: Array<{ type: 'event'; event: HistoryEvent }>
hasMore: boolean
}
@@ -72,11 +68,6 @@ interface HistoryEvent {
data: unknown
}
-interface HistoryChunkEvent extends HistoryEvent {
- seq: number
- time: number
-}
-
interface ProcessObservation {
readonly ready: Promise
readonly text: () => string
@@ -314,16 +305,9 @@ function assistantText(page: HistoryPage): string {
return text.join('\n')
}
-/** Expand lossless history records for assertions over the public event stream. */
+/** Read scalar v2 history records for assertions over the public event stream. */
function historyEvents(page: HistoryPage): HistoryEvent[] {
- return page.records.flatMap(record => record.type === 'event'
- ? [record.event]
- : decodeStorageRecord({
- type: record.event.type.replace(/^chunkrow\//u, ''),
- seq0: record.event.seq,
- time0: record.event.time,
- data: record.event.data,
- }))
+ return page.records.map(record => record.event)
}
/** Stop the spawned CLI through its normal signal path, escalating only on a stuck teardown. */
diff --git a/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts
index f89e711382..5cb06b34bf 100644
--- a/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts
+++ b/apps/cli/tests/profiles/acp/tests/goal.expected.e2e.ts
@@ -2,7 +2,9 @@ import { readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
+ fixtureContext,
normalizeSessionSnapshot,
+ normalizeSessionSnapshots,
normalizeStdout,
runScenario,
type AgentUnderTest,
@@ -62,6 +64,13 @@ function normalizeGoalLog(content: string, context: NormalizeContext): string {
return normalizeGoalTimestamps(normalizeSessionSnapshot(content, context)) as string
}
+/** Compare one current normalized goal log with its generation-aware committed fixture. */
+async function expectGoalLog(actual: string, expectedPath: string): Promise {
+ const expected = await readFile(expectedPath, 'utf8')
+ expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parseJsonl))
+ .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parseJsonl))
+}
+
describe('same-session goal snapshot through the ACP automation driver', () => {
it('runs exact automatic rounds in the shipped application and persists cancellation', async () => {
const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as InputScript
@@ -109,7 +118,7 @@ describe('same-session goal snapshot through the ACP automation driver', () => {
])
}
expect(stdout).toBe(await readFile(stdoutExpected, 'utf8'))
- expect(session).toBe(await readFile(sessionExpected, 'utf8'))
+ await expectGoalLog(session, sessionExpected)
})
it('injects the wrap-up instruction after an autonomous completion and delivers a closing message', async () => {
@@ -168,6 +177,6 @@ describe('same-session goal snapshot through the ACP automation driver', () => {
])
}
expect(stdout).toBe(await readFile(wrapupStdoutExpected, 'utf8'))
- expect(session).toBe(await readFile(wrapupSessionExpected, 'utf8'))
+ await expectGoalLog(session, wrapupSessionExpected)
})
})
diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
index b87618c6ad..469b1a6db6 100644
--- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
@@ -6,10 +6,12 @@ import { fileURLToPath } from 'node:url'
import {
normalizeSessionLog,
normalizeSessionSnapshot,
+ normalizeSessionSnapshots,
normalizeStdout,
scrubRequestHeaders,
type NormalizeContext,
} from '@deepseek-ai/dsh-session-snapshot'
+import { prepareSessionEventNotificationsForComparison } from '@deepseek-ai/dsh-llm-replay'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import {
decompressZstdFrame,
@@ -61,6 +63,24 @@ interface DeepSeekDefaultsServer {
close(): Promise
}
+/** Compare one current Session with an older committed generation in memory. */
+async function expectSessionSnapshot(
+ actual: string,
+ context: NormalizeContext,
+ expectedPath: string,
+): Promise {
+ const [normalizedActual] = normalizeSessionSnapshots([actual], context)
+ const expected = await readFile(expectedPath, 'utf8')
+ const [normalizedExpected] = normalizeSessionSnapshots([expected], context, { sourcePaths: [expectedPath] })
+ expect(parseJsonl(normalizedActual ?? '')).toEqual(parseJsonl(normalizedExpected ?? ''))
+}
+
+/** Compare current headless session-event wrappers with a committed v1 stream. */
+async function expectHeadlessStream(normalized: string, expectedPath: string): Promise {
+ const expected = prepareSessionEventNotificationsForComparison(await readFile(expectedPath, 'utf8'))
+ expect(parseJsonl(normalized)).toEqual(parseJsonl(expected))
+}
+
/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
async function deepseekDefaultsServer(): Promise {
const requests: JsonObject[] = []
@@ -218,7 +238,7 @@ describe('headless stream-json snapshots', () => {
const context = contextFromLogs([actual.content])
const session = normalizeSessionSnapshot(actual.content, context)
if (refreshing) await writeFile(headlessSessionExpected, session)
- await expect(session).toMatchFileSnapshot(headlessSessionExpected)
+ await expectSessionSnapshot(session, context, headlessSessionExpected)
expect(session).toContain(task)
expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP')
},
@@ -303,7 +323,7 @@ describe('headless stream-json snapshots', () => {
expect(result.stderr).toBe('')
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
if (refreshing) await writeFile(streamExpected, normalized)
- expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+ await expectHeadlessStream(normalized, streamExpected)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('logs actionable missing-credential guidance through the one-shot app', async () => {
@@ -332,7 +352,7 @@ describe('headless stream-json snapshots', () => {
expect(result.stderr).toBe('')
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
if (refreshing) await writeFile(streamExpected, normalized)
- expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+ await expectHeadlessStream(normalized, streamExpected)
// The durable failure leads with the credential store — the path that
// keeps the secret out of configuration files — then names the launching
// environment, and stops there: configuration carries the reference, so
@@ -369,7 +389,7 @@ describe('headless stream-json snapshots', () => {
expect(result.stderr).toBe('')
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
if (refreshing) await writeFile(streamExpected, normalized)
- expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+ await expectHeadlessStream(normalized, streamExpected)
// The durable failure names the reference to correct and the writer that
// usually owns it, and stays true in a composition that mounts no Models
// page at all.
@@ -663,7 +683,7 @@ describe('headless stream-json snapshots', () => {
expect(result.stderr).toBe('')
const normalized = normalizeGoalStream(result.stdout, runCwd)
if (refreshing) await writeFile(streamExpected, normalized)
- expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+ await expectHeadlessStream(normalized, streamExpected)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('delivers a continuable child result without parent polling', async () => {
@@ -720,7 +740,7 @@ describe('headless stream-json snapshots', () => {
const context = contextFromLogs([parent.content, child.content])
const normalizedChild = normalizeSessionSnapshot(child.content, context)
if (refreshing) await writeFile(childExpected, normalizedChild)
- await expect(normalizedChild).toMatchFileSnapshot(childExpected)
+ await expectSessionSnapshot(normalizedChild, context, childExpected)
expect(normalizedChild).toContain('CHILD_RESULT')
expect(normalizedChild).not.toContain('"name":"report"')
},
@@ -734,6 +754,6 @@ describe('headless stream-json snapshots', () => {
})
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
if (refreshing) await writeFile(streamExpected, normalized)
- expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+ await expectHeadlessStream(normalized, streamExpected)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
diff --git a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts
index 498a4e9499..ffd3defb49 100644
--- a/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/semantic-checkpoint.expected.e2e.ts
@@ -2,7 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
-import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot'
+import {
+ fixtureContext,
+ normalizeSessionSnapshot,
+ normalizeSessionSnapshots,
+ type NormalizeContext,
+} from '@deepseek-ai/dsh-session-snapshot'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
@@ -20,6 +25,16 @@ const sessionId = SessionId('semantic-checkpoint-unknown-outcome')
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
const task = 'Continue safely from the interrupted operation.'
+/** Compare one current normalized Session with its generation-aware committed fixture. */
+async function expectSession(actual: string, expectedPath: string): Promise {
+ const expected = await readFile(expectedPath, 'utf8')
+ const parse = (content: string): Record[] => content.split('\n')
+ .filter(line => line.trim().length > 0)
+ .map(line => JSON.parse(line) as Record)
+ expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse))
+ .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse))
+}
+
async function seedInterruptedSession(root: string, cwd: string): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -45,6 +60,7 @@ async function seedInterruptedSession(root: string, cwd: string): Promise {
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
const session = normalizeSessionSnapshot(await readFile(sessionPath, 'utf8'), normalization)
if (refreshing) await writeFile(sessionExpected, session)
- expect(session).toBe(await readFile(sessionExpected, 'utf8'))
+ await expectSession(session, sessionExpected)
expect(session).toContain('TOOL_OUTCOME_UNKNOWN')
expect(session).toContain('Do not retry blindly.')
},
diff --git a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
index 3119b39aa4..0c5c2d0584 100644
--- a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
@@ -1,7 +1,7 @@
/**
* Assembled-app regressions for Session-format lifecycle behavior: a physical
* v0 log migrates through the real Loader composition, remains byte-for-byte
- * intact beside v1, and accepts the next append through v1; a future format or unknown
+ * intact beside v2, and accepts the next append through v2; a future format or unknown
* required current event refuses with the direction and raw log path.
* @module session-format-guard-snapshot
*/
@@ -46,7 +46,11 @@ async function seedSession(root: string, cwd: string, version: number, events: S
const location = ctx.sessionPersistence.locate(meta)
if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
const content = [
- { type: 'session', version, id: sessionId, createdAt: 1, cwd, delegationDepth: 0 },
+ {
+ type: 'session', version, id: sessionId, createdAt: 1, cwd,
+ ...(version >= 2 ? { isSeeded: false } : {}),
+ delegationDepth: 0,
+ },
...events,
].map(record => JSON.stringify(record)).join('\n') + '\n'
const path = join(dirname(location.path), generationLogFilename(version, 'none'))
@@ -66,7 +70,7 @@ function closedTurn(): SessionEvent[] {
}
describe('session format guard through the assembled app', () => {
- it('migrates a raw v0 log before resume, preserves exact source bytes, and appends only to v1', async () => {
+ it('migrates a raw v0 log before resume, preserves exact source bytes, and appends only to v2', async () => {
let v0Path = ''
let v0 = ''
let v0Identity: { readonly dev: bigint; readonly ino: bigint } | undefined
@@ -97,7 +101,7 @@ describe('session format guard through the assembled app', () => {
.toBe(SESSION_FORMAT_VERSION)
expect(current).not.toBe(v0)
expect(current.trimEnd().split('\n').length).toBeGreaterThan(closedTurn().length + 1)
- expect((await readdir(dirname(v0Path))).sort()).toEqual(['session.jsonl', 'session.v1.jsonl'])
+ expect((await readdir(dirname(v0Path))).sort()).toEqual(['session.jsonl', 'session.v2.jsonl'])
},
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts
index e3c80f9219..656506140b 100644
--- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts
@@ -8,7 +8,12 @@ import { readFile, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
-import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot'
+import {
+ fixtureContext,
+ normalizeSessionSnapshot,
+ normalizeSessionSnapshots,
+ type NormalizeContext,
+} from '@deepseek-ai/dsh-session-snapshot'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
@@ -26,6 +31,16 @@ const childId = SessionId('subagent-diagnostic-child')
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
const task = 'Call list_agents once and report what it shows.'
+/** Compare one current normalized Session with its generation-aware committed fixture. */
+async function expectSession(actual: string, expectedPath: string): Promise {
+ const expected = await readFile(expectedPath, 'utf8')
+ const parse = (content: string): Record[] => content.split('\n')
+ .filter(line => line.trim().length > 0)
+ .map(line => JSON.parse(line) as Record)
+ expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse))
+ .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse))
+}
+
/**
* Seed a completed parent turn plus one cold child that durably classifies
* as a subagent (`origin`) but never appended its descriptor event — the
@@ -107,7 +122,7 @@ describe('descriptor-less cold child diagnostic snapshot', () => {
if (refreshing) {
await writeFile(parentExpected, normalizedParent)
}
- expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8'))
+ await expectSession(normalizedParent, parentExpected)
},
})
diff --git a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts
index 9f9da8707d..47562d5544 100644
--- a/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/subagent-inheritance.expected.e2e.ts
@@ -7,7 +7,12 @@ import { readFile, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
-import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot'
+import {
+ fixtureContext,
+ normalizeSessionSnapshot,
+ normalizeSessionSnapshots,
+ type NormalizeContext,
+} from '@deepseek-ai/dsh-session-snapshot'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
@@ -26,6 +31,16 @@ const sessionId = SessionId('subagent-inheritance-parent')
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
const task = 'Delegate the write probe to a subagent.'
+/** Compare one current normalized Session with its generation-aware committed fixture. */
+async function expectSession(actual: string, expectedPath: string): Promise {
+ const expected = await readFile(expectedPath, 'utf8')
+ const parse = (content: string): Record[] => content.split('\n')
+ .filter(line => line.trim().length > 0)
+ .map(line => JSON.parse(line) as Record)
+ expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse))
+ .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse))
+}
+
/** Seed a completed parent turn with its read-only policy and current LLM selection. */
async function seedReadOnlyParent(root: string, cwd: string): Promise {
const ctx = new Context()
@@ -141,8 +156,8 @@ describe('parent-only override inheritance snapshot', () => {
await writeFile(parentExpected, normalizedParent)
await writeFile(childExpected, normalizedChild)
}
- expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8'))
- expect(normalizedChild).toBe(await readFile(childExpected, 'utf8'))
+ await expectSession(normalizedParent, parentExpected)
+ await expectSession(normalizedChild, childExpected)
// The child's real write was denied by the real fence.
expect(normalizedChild).toContain('file access denied under read-only mode')
},
diff --git a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts
index 2fded92b5b..e04cba22ad 100644
--- a/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/workspace-context-resume.expected.e2e.ts
@@ -8,7 +8,12 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
-import { normalizeSessionSnapshot, type NormalizeContext } from '@deepseek-ai/dsh-session-snapshot'
+import {
+ fixtureContext,
+ normalizeSessionSnapshot,
+ normalizeSessionSnapshots,
+ type NormalizeContext,
+} from '@deepseek-ai/dsh-session-snapshot'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, {
@@ -36,6 +41,16 @@ const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
const oldInstruction = 'Old workspace instruction.'
const newInstruction = 'New workspace instruction after offline edit.'
+/** Compare one current normalized Session with its generation-aware committed fixture. */
+async function expectSession(actual: string, expectedPath: string): Promise {
+ const expected = await readFile(expectedPath, 'utf8')
+ const parse = (content: string): Record[] => content.split('\n')
+ .filter(line => line.trim().length > 0)
+ .map(line => JSON.parse(line) as Record)
+ expect(normalizeSessionSnapshots([actual], fixtureContext(actual), { sourcePaths: [expectedPath] }).map(parse))
+ .toEqual(normalizeSessionSnapshots([expected], fixtureContext(expected), { sourcePaths: [expectedPath] }).map(parse))
+}
+
interface SeedBaselineOptions {
files?: Array<{ name: string; content: string }>
instructionFileCandidates?: string[]
@@ -139,7 +154,7 @@ describe('agent-instructions resume snapshot', () => {
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
const session = normalizeSessionSnapshot(await readFile(sessionPath, 'utf8'), normalization)
if (refreshing) await writeFile(sessionExpected, session)
- expect(session).toBe(await readFile(sessionExpected, 'utf8'))
+ await expectSession(session, sessionExpected)
const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as {
type?: string
@@ -206,7 +221,7 @@ describe('agent-instructions resume snapshot', () => {
await mkdir(dirname(precedenceExpected), { recursive: true })
await writeFile(precedenceExpected, session)
}
- expect(session).toBe(await readFile(precedenceExpected, 'utf8'))
+ await expectSession(session, precedenceExpected)
const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as {
type?: string
diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts
index 2601dc20f9..9ebea7cdd0 100644
--- a/apps/web/tests/agent-team-panel.e2e.ts
+++ b/apps/web/tests/agent-team-panel.e2e.ts
@@ -64,6 +64,7 @@ describe('web e2e: Agent Teams panel', () => {
}), { surfaceOp: 'append' })
agent.session.append('step/start', { turn: 1, step: 1 })
agent.session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts
index 5e58d868c5..c539a96601 100644
--- a/apps/web/tests/chat-continuous-conversation.e2e.ts
+++ b/apps/web/tests/chat-continuous-conversation.e2e.ts
@@ -9,7 +9,7 @@ import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import { ToolCallId, expandAssistantStream, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
@@ -276,7 +276,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
event.type === 'turn/end'
))
- const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
+ const chunks = turnEvents.flatMap(event => (
+ event.type === 'assistant/message' || event.type === 'assistant/attempt'
+ ? expandAssistantStream(event.data.stream)
+ : []
+ ))
expect(turnStarts).toHaveLength(1)
expect(turnStarts[0]?.data.turn).toBe(spec.index)
@@ -340,8 +344,11 @@ describe('web e2e: continuous conversation grown through the composer', () => {
'[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
).count()).toBe(0)
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
- expect(sessionEvents.filter(event => (
- event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
+ expect(sessionEvents.flatMap(event => (
+ (event.type === 'assistant/message' || event.type === 'assistant/attempt')
+ && event.data.turn === TURN_COUNT
+ ? expandAssistantStream(event.data.stream)
+ : []
)).length).toBeGreaterThan(30)
expect(consoleWarnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 49e14028ae..761026e211 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -10,6 +10,7 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
@@ -81,6 +82,7 @@ interface FlowAnchor {
}
interface ScrollWorld {
+ readonly assistantFrames: AssistantStreamFrame[]
readonly events: SessionEvent[]
readonly page: Page
readonly replayDir?: string
@@ -164,7 +166,9 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise { events.push(event) })
+ scaffold.ctx.on('agent/assistant-stream', ({ frame }) => { assistantFrames.push(frame) })
page = await newEnglishPage(browser, 900)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -175,6 +179,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise {
await wheelTranscript(world.page, 420)
const readerAnchor = await visibleFlowAnchor(world.page)
- const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
+ const chunksAfterAnchor = world.assistantFrames.filter(frame => frame.type === 'chunk').length
await expect.poll(
- () => world.events.filter(event => event.type === 'assistant/chunk').length,
+ () => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
{ timeout: 10_000 },
).toBeGreaterThan(chunksAfterAnchor + 5)
@@ -659,36 +664,32 @@ describe('web e2e: long Chat scroll contract', () => {
await wheelTranscript(world.page, -1_200)
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
const awayAnchor = await visibleFlowAnchor(world.page)
- const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
+ const chunksBeforeRelease = world.assistantFrames.filter(frame => frame.type === 'chunk').length
await writeFile(releasePath, 'release\n')
released = true
await expect.poll(
- () => world.events.some(event => event.type === 'tool/result'),
- { timeout: 15_000 },
- ).toBe(true)
- await expect.poll(
- () => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
- { timeout: 15_000 },
- ).toBe(true)
- await expect.poll(
- () => world.events.filter(event => event.type === 'assistant/chunk').length,
+ () => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksBeforeRelease + 5)
- await expectSameFlowTop(world.page, awayAnchor)
+ await nextPaint(world.page)
+ expect(world.events.some(event => event.type === 'tool/result')).toBe(true)
+ expect(Math.abs((await flowTop(world.page, awayAnchor.key)) - awayAnchor.top))
+ .toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
- const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
+ const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
- await expectBottom(world.page)
await expect.poll(
- () => world.events.filter(event => event.type === 'assistant/chunk').length,
+ () => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksAtRepin + 5)
- await expectBottom(world.page)
+ await nextPaint(world.page)
+ expect(Math.abs((await scrollGeometry(world.page)).distanceFromBottom)).toBeLessThanOrEqual(1)
} finally {
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
}
await settled
+ expect(world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST))).toBe(true)
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expectBottom(world.page)
@@ -892,7 +893,7 @@ describe('web e2e: long Chat scroll contract', () => {
await flingTranscript(world.page, -900)
await backToBottom.waitFor({ timeout: 10_000 })
const awayAnchor = await visibleFlowAnchor(world.page)
- const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
+ const chunksBeforeRelease = world.assistantFrames.filter(frame => frame.type === 'chunk').length
await writeFile(releasePath, 'release\n')
released = true
await expect.poll(
@@ -900,7 +901,7 @@ describe('web e2e: long Chat scroll contract', () => {
{ timeout: 15_000 },
).toBe(true)
await expect.poll(
- () => world.events.filter(event => event.type === 'assistant/chunk').length,
+ () => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksBeforeRelease + 5)
await expectSameFlowTop(world.page, awayAnchor)
@@ -914,9 +915,9 @@ describe('web e2e: long Chat scroll contract', () => {
}
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
- const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
+ const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length
await expect.poll(
- () => world.events.filter(event => event.type === 'assistant/chunk').length,
+ () => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksAtRepin + 5)
await expectBottom(world.page)
diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts
index 5f59aa48e5..48784538f8 100644
--- a/apps/web/tests/chat-scroll-fixture.ts
+++ b/apps/web/tests/chat-scroll-fixture.ts
@@ -76,6 +76,7 @@ function appendRequestHeader(session: Session, turn: number, step: number): void
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
session.append('assistant/message', {
+ stream: [],
turn,
step,
message: createAssistantMessage({
@@ -114,6 +115,7 @@ function appendToolStep(
})
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createAssistantMessage({
@@ -162,6 +164,7 @@ function fixtureLog(session: Session): string {
id: '{{sessionId}}',
createdAt: Date.now() - 60_000,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify(event)),
diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts
index 4ba4d4b727..e50a5bba5e 100644
--- a/apps/web/tests/complex-history.perf.ts
+++ b/apps/web/tests/complex-history.perf.ts
@@ -15,6 +15,7 @@ import {
createAssistantMessage,
createToolResultMessage,
createUserMessage,
+ expandAssistantStream,
} from '@deepseek-ai/dsh-llm'
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session'
@@ -212,6 +213,7 @@ function appendAssistant(
body: string,
): void {
session.append('assistant/message', {
+ stream: [],
turn,
step,
message: createAssistantMessage({
@@ -243,6 +245,7 @@ function appendToolStep(
})
session.append('assistant/message', {
+ stream: [],
turn,
step,
message: createAssistantMessage({
@@ -310,6 +313,8 @@ function fixtureLog(session: Session): string {
id: '{{sessionId}}',
createdAt: Date.now() - 60_000,
cwd: '{{cwd}}',
+ isSeeded: false,
+ delegationDepth: 0,
}
return [
JSON.stringify(header),
@@ -977,7 +982,11 @@ async function continueConversation(
const streamAfter = await chromiumMetrics(cdp)
const mutations = await stopMutationProbe(world.page)
const turnEvents = world.sessionEvents.slice(eventStart)
- const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
+ const chunks = turnEvents.flatMap(event => (
+ event.type === 'assistant/message' || event.type === 'assistant/attempt'
+ ? expandAssistantStream(event.data.stream)
+ : []
+ ))
const toolCalls = turnEvents.filter(event => event.type === 'tool/call')
const toolResults = turnEvents.filter(event => event.type === 'tool/result')
const toolTurn = spec.toolResultMarker !== undefined
@@ -1095,7 +1104,11 @@ async function measurePostSoakUserRender(
const fullTurnMs = performance.now() - fullTurnStarted
const turnEvents = world.sessionEvents.slice(eventStart)
- const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
+ const chunks = turnEvents.flatMap(event => (
+ event.type === 'assistant/message' || event.type === 'assistant/attempt'
+ ? expandAssistantStream(event.data.stream)
+ : []
+ ))
const user = turnEvents.find(
event => event.type === 'user/message' && event.data.source.kind === 'user',
)
diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts
index 17ec2f515c..237bbd9d3d 100644
--- a/apps/web/tests/markdown-cjk-strong.e2e.ts
+++ b/apps/web/tests/markdown-cjk-strong.e2e.ts
@@ -49,6 +49,7 @@ function markdownFixture(): string {
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -75,6 +76,7 @@ function markdownFixture(): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify({
diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts
index 78288fbb45..54ac8ddc9e 100644
--- a/apps/web/tests/markdown-images.e2e.ts
+++ b/apps/web/tests/markdown-images.e2e.ts
@@ -96,6 +96,7 @@ function markdownImageFixture(remoteUrl: string): string {
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -124,6 +125,7 @@ function markdownImageFixture(remoteUrl: string): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}
return [
diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts
index 338574b25c..62bbf589aa 100644
--- a/apps/web/tests/markdown-inline-code-links.e2e.ts
+++ b/apps/web/tests/markdown-inline-code-links.e2e.ts
@@ -39,6 +39,7 @@ function markdownFixture(linkUrl: string): string {
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -72,6 +73,7 @@ function markdownFixture(linkUrl: string): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify({
diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts
index eed8d1fdd6..44a992b6f1 100644
--- a/apps/web/tests/markdown-wide-table.e2e.ts
+++ b/apps/web/tests/markdown-wide-table.e2e.ts
@@ -119,6 +119,7 @@ function wideTableFixture(): string {
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -135,6 +136,7 @@ function wideTableFixture(): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}
return [
diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts
index 157a7fd810..5f5c89ead4 100644
--- a/apps/web/tests/math-rendering.e2e.ts
+++ b/apps/web/tests/math-rendering.e2e.ts
@@ -41,6 +41,7 @@ function mathFixture(): string {
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -76,6 +77,7 @@ function mathFixture(): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify({
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
index c91882a6fc..6cc0ca7a19 100644
--- a/apps/web/tests/message-actions.e2e.ts
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -26,6 +26,7 @@ const SEED_ID = 'message-actions-web-e2e'
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
const MID_TURN_TEXT = 'I will read both files before answering.'
+const INTERRUPTED_REASONING = 'Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I\'ll now reply with DONE as instructed.'
const SECOND_PROMPT = 'Now give the final answer.'
/**
@@ -37,25 +38,40 @@ const SECOND_PROMPT = 'Now give the final answer.'
*/
function completedTailFixture(raw: string): string {
const decoded = parseSeedFixture(raw)
- const kept = decoded.events.filter(event => event.seq < 101).map((event) => {
- if (event.type === 'assistant/message' && event.seq === 64) {
- const data = event.data as unknown as { message?: { content?: unknown[] } }
- const content = data.message?.content
- if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content')
- return {
- ...event,
- data: {
- ...data,
- message: {
- ...data.message,
- content: [...content.slice(0, 1), { type: 'text', text: MID_TURN_TEXT }, ...content.slice(1)],
- },
+ const stepTwoStart = decoded.events.findIndex(event =>
+ event.type === 'step/start' && event.data.turn === 1 && event.data.step === 2)
+ if (stepTwoStart < 0) throw new Error('borrowed fixture has no step-two start')
+ const kept = decoded.events.slice(0, stepTwoStart + 1).map((event) => {
+ if (event.type !== 'assistant/message' || event.data.turn !== 1 || event.data.step !== 1) return event
+ const finish = event.data.stream.findIndex(record =>
+ record.type === 'chunk' && record.chunk.type === 'finish')
+ if (finish < 0) throw new Error('borrowed step-one Assistant message has no finish record')
+ const finishRecord = event.data.stream[finish]!
+ if (finishRecord.type !== 'chunk') throw new Error('borrowed step-one finish is not a chunk record')
+ const streamTime = finishRecord.time
+ return {
+ ...event,
+ data: {
+ ...event.data,
+ message: {
+ ...event.data.message,
+ content: [...event.data.message.content, { type: 'text' as const, text: MID_TURN_TEXT }],
},
- }
+ stream: [
+ ...event.data.stream.slice(0, finish),
+ { type: 'chunk' as const, time: streamTime, chunk: { type: 'block-start' as const, index: 3, blockType: 'text' as const } },
+ { type: 'text-chunks' as const, time0: streamTime, index: 3, dt: [], texts: [MID_TURN_TEXT] },
+ {
+ type: 'chunk' as const,
+ time: streamTime,
+ chunk: { type: 'block-end' as const, index: 3, block: { type: 'text' as const, text: MID_TURN_TEXT } },
+ },
+ ...event.data.stream.slice(finish),
+ ],
+ },
}
- return event
})
- let seq = kept.length
+ let seq = (kept.at(-1)?.seq ?? -1) + 1
let time = (kept.at(-1)?.time ?? -1) + 1
const at = (event: Record): { seq: number; time: number } & Record => ({
...event,
@@ -63,12 +79,57 @@ function completedTailFixture(raw: string): string {
time: time++,
})
const tail = [
+ at({
+ type: 'assistant/attempt',
+ data: {
+ turn: 1,
+ step: 2,
+ stream: [
+ { type: 'chunk', time, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
+ { type: 'reasoning-chunks', time0: time, index: 0, dt: [], texts: [INTERRUPTED_REASONING] },
+ {
+ type: 'chunk',
+ time,
+ chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: INTERRUPTED_REASONING } },
+ },
+ { type: 'chunk', time, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+ ],
+ },
+ }),
at({ type: 'step/end', data: { turn: 1, step: 2 } }),
- at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'aborted' } } }),
- at({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } }),
- at({ type: 'user/message', data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }),
+ at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } } }),
+ at({ type: 'turn/start', data: { turn: 2 } }),
+ at({
+ type: 'user/message',
+ data: {
+ content: [{ type: 'text', text: SECOND_PROMPT }],
+ source: { kind: 'user' },
+ role: 'user',
+ id: '{{message:98}}',
+ },
+ surfaceOp: 'append',
+ }),
at({ type: 'step/start', data: { turn: 2, step: 1 } }),
- at({ type: 'assistant/message', data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' }),
+ at({
+ type: 'assistant/message',
+ data: {
+ turn: 2,
+ step: 1,
+ message: {
+ role: 'assistant',
+ content: [{ type: 'text', text: 'DONE' }],
+ source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+ id: '{{message:99}}',
+ },
+ stream: [
+ { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
+ { type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['DONE'] },
+ { type: 'chunk', time: 0, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: 'DONE' } } },
+ { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+ ],
+ },
+ surfaceOp: 'append',
+ }),
at({ type: 'step/end', data: { turn: 2, step: 1 } }),
at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }),
]
diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts
index 76d71f7fb7..656285cbf9 100644
--- a/apps/web/tests/preview-boot.e2e.ts
+++ b/apps/web/tests/preview-boot.e2e.ts
@@ -34,6 +34,7 @@ import {
IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION,
type PreviewFixtureManifest,
} from '@deepseek-ai/dsh-experimental-webworker-runtime'
+import { buildVfsExampleFiles } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts'
import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
@@ -42,9 +43,6 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
/** Where the client looks for the image: the runtime's own name, beside the page. */
const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME)
-/** Built-in source catalog read by the pre-boot chooser. */
-const FIXTURE_MANIFEST_FILE = join(DIST_ROOT, 'preview', PREVIEW_FIXTURE_MANIFEST_FILE)
-
/** Keyless browser golden for the pre-Worker source chooser. */
const SOURCE_CHOOSER_EXPECTED = fileURLToPath(new URL('./snapshots/preview-boot/source-chooser.expected.md', import.meta.url))
@@ -114,12 +112,13 @@ function requirePreviewPages(): void {
}
/**
- * The base image, fixture manifest, and overlays to serve, packed here when
- * `dist/` does not carry the complete set: `pnpm run build` emits the pages but
- * only `build:preview` packs these files, so this lane packs for itself rather
- * than skipping the deployment it accepts. A complete built set is used as it
- * stands — the worker refuses a base lowered against another wrapper contract.
- * Self-packed files land in a temp directory, never in `dist/`: the
+ * The base image, fixture manifest, and overlays to serve. `pnpm run build`
+ * emits the pages but only `build:preview` packs the image, so this lane packs
+ * a missing image rather than skipping the deployment it accepts. The example
+ * overlay pairs its committed Session generations with the generator-owned
+ * current projection cache. The worker therefore exercises historical reads
+ * without relying on a stale cache schema. Generated files land in a temp
+ * directory, never in `dist/`: the
* client-artifact digest record treats `dist/` as build-owned, so a test write
* there fails the record check for every later consumer.
* @returns Static-path overrides and their teardown.
@@ -128,21 +127,6 @@ function requirePreviewPages(): void {
*/
function requireVfsAssets(): PreviewAssets {
const fixtureDefinitions = previewFixtures(REPO_ROOT)
- const fixtureFiles = fixtureDefinitions.map(fixture =>
- join(DIST_ROOT, 'preview', 'fixtures', `${fixture.id}.tar.gz`))
- if ([IMAGE_FILE, FIXTURE_MANIFEST_FILE, ...fixtureFiles].every(existsSync)) {
- return { overrides: new Map(), cleanup: () => {} }
- }
- const packed = packVfsImage({
- config: composeProfile(REPO_ROOT, PROFILE),
- profile: PROFILE,
- workspaces: indexWorkspacePackages(REPO_ROOT),
- resolveFrom: REPO_ROOT,
- configTrees: configTrees(REPO_ROOT),
- })
- if (packed.missing.length > 0) {
- throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
- }
const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-'))
const overrides = new Map()
const writeAsset = (relativePath: string, bytes: Uint8Array | string): void => {
@@ -151,10 +135,30 @@ function requireVfsAssets(): PreviewAssets {
writeFileSync(path, bytes)
overrides.set(relativePath, path)
}
- writeAsset(`preview/${IMAGE_FILE_NAME}`, packed.image)
+ if (!existsSync(IMAGE_FILE)) {
+ const packed = packVfsImage({
+ config: composeProfile(REPO_ROOT, PROFILE),
+ profile: PROFILE,
+ workspaces: indexWorkspacePackages(REPO_ROOT),
+ resolveFrom: REPO_ROOT,
+ configTrees: configTrees(REPO_ROOT),
+ })
+ if (packed.missing.length > 0) {
+ throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
+ }
+ writeAsset(`preview/${IMAGE_FILE_NAME}`, packed.image)
+ }
+ const currentCache = buildVfsExampleFiles().get('home/storages/session_projcache.json')
+ if (currentCache === undefined) throw new Error('preview boot: generated example has no projection cache')
+ const cacheDirectory = join(directory, 'current-projection-cache')
+ mkdirSync(cacheDirectory, { recursive: true })
+ writeFileSync(join(cacheDirectory, 'session_projcache.json'), currentCache)
const fixtures = fixtureDefinitions.map((fixture) => {
const relativePath = `preview/fixtures/${fixture.id}.tar.gz`
- writeAsset(relativePath, packVfsOverlay(fixture.trees).image)
+ const trees = fixture.id === 'vfs-example'
+ ? [...fixture.trees, { mount: 'home/storages', directory: cacheDirectory }]
+ : fixture.trees
+ writeAsset(relativePath, packVfsOverlay(trees).image)
return {
id: fixture.id,
label: fixture.label,
@@ -175,7 +179,7 @@ function requireVfsAssets(): PreviewAssets {
* Answer one request with its generated override or the file under `dist/`.
* @param request - Incoming request; only its path is read.
* @param response - Response to write the bytes or the 404 to.
- * @param overrides - Generated deployment files used when `dist/` has none.
+ * @param overrides - Generated deployment files served before `dist/`.
*/
async function respond(
request: IncomingMessage,
diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts
index 47b1744d55..b8ec77c693 100644
--- a/apps/web/tests/produced-file-mentions.e2e.ts
+++ b/apps/web/tests/produced-file-mentions.e2e.ts
@@ -50,6 +50,7 @@ function mentionFixture(): string {
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
}))
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createAssistantMessage({
@@ -80,8 +81,10 @@ function mentionFixture(): string {
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
+ session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 2,
message: createAssistantMessage({
@@ -106,6 +109,7 @@ function mentionFixture(): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify({
diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts
index 6da856ea8a..7faeefa411 100644
--- a/apps/web/tests/produced-files.e2e.ts
+++ b/apps/web/tests/produced-files.e2e.ts
@@ -53,6 +53,7 @@ function producedFixture(): string {
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
}))
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createAssistantMessage({
@@ -79,8 +80,10 @@ function producedFixture(): string {
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
+ session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 2,
message: createAssistantMessage({
@@ -94,7 +97,7 @@ function producedFixture(): string {
return [
JSON.stringify({
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
- createdAt: 0, cwd: '{{cwd}}', delegationDepth: 0,
+ createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify({
...event, time: eventTimeOrigin + event.seq * 1_000,
diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts
index 8e53eb82dc..d11977ee7c 100644
--- a/apps/web/tests/reference-composer.e2e.ts
+++ b/apps/web/tests/reference-composer.e2e.ts
@@ -68,6 +68,7 @@ function sourceSessionFixture(): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify(event)),
@@ -116,6 +117,7 @@ function targetSessionFixture(): string {
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
+ isSeeded: false,
delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify(event)),
diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts
index a4899f5e57..a6781a41be 100644
--- a/apps/web/tests/replay-round-trip.e2e.ts
+++ b/apps/web/tests/replay-round-trip.e2e.ts
@@ -4,8 +4,8 @@
// or the live adapter (record). Drive steps run in every mode and wait only
// on generic completion (whenTurnSettled — never model-content selectors, so
// record cannot hang on a live model answering differently); assertion steps
-// run in replay/refresh only. Settled states only — streaming incrementality
-// is asserted from the persisted assistant/chunk events, not transient DOM.
+// run in replay/refresh only. Settled states only — streaming fidelity is
+// asserted from the durable embedded Assistant stream, not transient DOM.
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
import { readFile } from 'node:fs/promises'
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
@@ -154,8 +154,10 @@ describe('web e2e: fresh round trip through the real assembly', () => {
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
expect(turnEnds.length).toBe(1)
expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')
- // The persisted chunk events are the authoritative incrementality proof.
- expect(sessionEvents.filter(e => e.type === 'assistant/chunk').length).toBeGreaterThan(10)
+ // Embedded stream members are the authoritative incrementality proof.
+ expect(sessionEvents.flatMap(e => e.type === 'assistant/message' || e.type === 'assistant/attempt'
+ ? expandAssistantStream(e.data.stream)
+ : []).length).toBeGreaterThan(10)
}, 60_000)
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
diff --git a/apps/web/tests/scaffold-generation.spec.ts b/apps/web/tests/scaffold-generation.spec.ts
index bcfb6a2caa..67a25f4734 100644
--- a/apps/web/tests/scaffold-generation.spec.ts
+++ b/apps/web/tests/scaffold-generation.spec.ts
@@ -7,6 +7,7 @@ import {
fixtureIdentity,
normalizeAria,
normalizeWebSessionVolatiles,
+ parseSeedFixture,
realizeSeedFixture,
recordedSessionFixturePath,
selectedSessionFixture,
@@ -143,12 +144,14 @@ describe('Web snapshot generation filenames', () => {
it('selects the highest parent and child generations without counting retained inputs twice', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-web-fixture-generations-'))
roots.push(root)
- for (const name of [
- 'session.jsonl',
- 'session.v2.jsonl',
- 'session.1.jsonl',
- 'session.1.v1.jsonl',
- ]) await writeFile(join(root, name), '')
+ for (const [name, version] of [
+ ['session.jsonl', 0],
+ ['session.v2.jsonl', 2],
+ ['session.1.jsonl', 0],
+ ['session.1.v1.jsonl', 1],
+ ] as const) {
+ await writeFile(join(root, name), `${JSON.stringify({ type: 'session', version })}\n`)
+ }
await expect(selectedSessionFixture(join(root, 'session.jsonl')))
.resolves.toBe(join(root, 'session.v2.jsonl'))
@@ -158,12 +161,43 @@ describe('Web snapshot generation filenames', () => {
.resolves.toBe(join(root, 'replay.override.json'))
})
- it('leaves an absent override-only parent fixture unresolved', async () => {
+ it('selects a current successor after its requested predecessor is removed', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-web-fixture-generations-'))
+ roots.push(root)
+ await writeFile(join(root, 'session.v2.jsonl'), `${JSON.stringify({ type: 'session', version: 2 })}\n`)
+
+ await expect(selectedSessionFixture(join(root, 'session.jsonl')))
+ .resolves.toBe(join(root, 'session.v2.jsonl'))
+ })
+
+ it('keeps an absent canonical fixture only for an override-only replay script', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-web-fixture-generations-'))
roots.push(root)
- await expect(selectedSessionFixture(join(root, 'session.jsonl')))
+ await expect(selectedSessionFixture(join(root, 'session.jsonl'), true))
.resolves.toBe(join(root, 'session.jsonl'))
+ await expect(selectedSessionFixture(join(root, 'session.jsonl')))
+ .rejects.toThrow('missing parent session fixture')
+ })
+
+ it('rejects a selected Web fixture whose filename and header generations disagree', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-web-fixture-generations-'))
+ roots.push(root)
+ await writeFile(join(root, 'session.v2.jsonl'), `${JSON.stringify({ type: 'session', version: 1 })}\n`)
+
+ await expect(selectedSessionFixture(join(root, 'session.jsonl')))
+ .rejects.toThrow('filename declares Session format v2, header declares v1')
+ })
+
+ it('projects a historical seed to a current header before callers append current events', () => {
+ const source = [
+ JSON.stringify({ type: 'session', version: 0, id: 'seed', createdAt: 1, delegationDepth: 0 }),
+ JSON.stringify({ type: 'turn/start', data: { turn: 1 } }),
+ JSON.stringify({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
+ '',
+ ].join('\n')
+
+ expect(parseSeedFixture(source).header).toMatchObject({ version: 2, isSeeded: false })
})
it('selects a committed sibling when the requested older generation is absent', async () => {
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index 23530d0e26..df50b83cf0 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -37,6 +37,7 @@ import Group from '@deepseek-ai/cordis-plugin-group'
import {
captureExpectedWorkspaceSnapshot,
captureWorkspaceSnapshot,
+ assertSessionFixtureVersion,
formatSystemPromptSnapshot,
formatToolSchemasSnapshot,
normalizedSystemPrompts,
@@ -47,8 +48,10 @@ import {
normalizeSessionSnapshots,
scrubRequestHeaders,
scrubSessionSnapshot,
+ sessionFixtureFiles,
sessionFixtureName,
stabilizeFixtureMessageIds,
+ stabilizeRefreshLog,
type NormalizeContext,
} from '@deepseek-ai/dsh-session-snapshot'
import {
@@ -68,6 +71,7 @@ import {
installLlmReplay,
parseSessionLog,
parseSessionLogForReplay,
+ prepareSessionSnapshotFixtureForComparison,
} from '@deepseek-ai/dsh-llm-replay'
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
@@ -147,18 +151,20 @@ async function ownsReplayFixture(replayFixture: string | undefined): Promise {
+export async function selectedSessionFixture(path: string, allowAbsent = false): Promise {
const requested = parseSessionFixtureName(basename(path))
if (requested === undefined) return path
- const selected = (await readdir(dirname(path)))
- .map(parseSessionFixtureName)
- .filter((candidate): candidate is NonNullable => candidate?.index === requested.index)
- .sort((left, right) => right.version - left.version)[0]
- // An override-only scenario deliberately has no projected parent role. Keep
- // the absent source only when no committed generation exists for that role.
- return selected === undefined ? path : join(dirname(path), selected.name)
+ const entries = await readdir(dirname(path))
+ if (allowAbsent && !entries.some(name => parseSessionFixtureName(name) !== undefined)) return path
+ const selected = sessionFixtureFiles(entries)
+ .find(candidate => candidate.index === requested.index)
+ if (selected === undefined) throw new Error(`${path}: missing Session fixture role ${requested.index}`)
+ const resolved = join(dirname(path), selected.name)
+ assertSessionFixtureVersion(selected.name, await readFile(resolved, 'utf8'))
+ return resolved
}
/**
@@ -404,10 +410,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise selectedSessionFixture(path)))
const compareReplaySession = options.compareReplaySession ?? await ownsReplayFixture(replayFixture)
const browserHost = options.remoteAuthority ?? '127.0.0.1'
if (mode === 'record') {
@@ -718,7 +724,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise (
- event.type === 'assistant/chunk' || event.type === 'request/header' || event.type === 'tool/call'
+ event.type === 'assistant/message' || event.type === 'assistant/attempt'
+ || event.type === 'request/header' || event.type === 'tool/call'
))
if (hasModelCall) {
throw new Error('replayProvidersOnly fixture must record no model calls')
@@ -855,7 +862,7 @@ function rawSessionLog(session: Session): string {
// Session validates durable payloads as JSON; its closed event unions do
// not carry the index signature used by the format package's JSON types.
events: session.snapshotEvents() as unknown as readonly SessionFormatEvent[],
- }, { packChunks: true })
+ }, { packChunks: false })
return [
JSON.stringify(encoded.header),
...encoded.rows.map(record => JSON.stringify(record)),
@@ -946,8 +953,23 @@ export function normalizeWebSessionVolatiles(log: string, workspaceCwd?: string)
}).join('\n')
}
-function stableSessionFixture(session: Session, existing: string, workspaceCwd: string): string {
- const fresh = scrubSessionSnapshot(normalizeWebSessionVolatiles(rawSessionLog(session), workspaceCwd))
+function stableSessionFixture(
+ session: Session,
+ existing: string,
+ workspaceCwd: string,
+ sourcePath?: string,
+): string {
+ const prepared = prepareSessionSnapshotFixtureForComparison(
+ normalizeWebSessionVolatiles(rawSessionLog(session), workspaceCwd),
+ sourcePath,
+ )
+ const stabilized = existing === ''
+ ? prepared
+ : stabilizeRefreshLog(prepared, existing, [], {
+ sessionIds: [String(session.id)],
+ cwd: workspaceCwd,
+ })
+ const fresh = scrubSessionSnapshot(stabilized)
.split(session.id).join('{{sessionId}}')
const stable = redactSessionSnapshotIds(stabilizeFixtureMessageIds([fresh], [existing]))[0]
if (stable === undefined) throw new Error('session harvest produced no stabilized fixture')
@@ -961,6 +983,10 @@ async function assertReplaySession(
webUrl: string,
): Promise {
let expected = await readFile(fixturePath, 'utf8')
+ const fixtureDir = dirname(fixturePath)
+ const manifestPath = join(fixtureDir, 'snapshot.yml')
+ const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
+ let expectedPath = fixturePath
const userPrompts = fixtureUserPrompts(expected, fixturePath)
const candidates = sessions.filter((session) => {
if (session.header.parentSession !== undefined) return false
@@ -976,9 +1002,10 @@ async function assertReplaySession(
const sessionCwd = session.header.cwd
if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`)
const actual = rawSessionLog(session)
- if (mode === 'refresh') {
- expected = stableSessionFixture(session, expected, sessionCwd)
- await writeFile(fixturePath, expected)
+ if (mode === 'refresh' && manifest.session === undefined) {
+ expected = stableSessionFixture(session, expected, sessionCwd, fixturePath)
+ expectedPath = recordedSessionFixturePath(fixturePath, session.header.version)
+ await writeFile(expectedPath, expected)
}
const expectedHeader = JSON.parse(expected.split('\n').find(line => line.trim() !== '') ?? '{}') as {
id?: unknown
@@ -991,12 +1018,9 @@ async function assertReplaySession(
}
expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`)
.toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext, {
- sourcePaths: [fixturePath],
+ sourcePaths: [expectedPath],
})[0])
- const fixtureDir = dirname(fixturePath)
- const manifestPath = join(fixtureDir, 'snapshot.yml')
- const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
if (manifest.header?.pin !== true) return
const normalizePrompt = (value: string): string => value
.split(REPO_ROOT).join('{{sourceRoot}}')
@@ -1018,6 +1042,7 @@ async function assertReplaySession(
* Record-mode fixture write-back: harvest the live session, scrub request
* headers to {{system}}/{{tools}}, tokenize the run-local cwd, redact opaque
* identities with typed relationship-preserving tokens, and write the fixture.
+ * A manifest-retained historical generation makes the write-back a no-op.
* @param scaffold - the record-mode scaffold.
* @param sessionId - the driven session.
* @param fixturePath - any committed generation for the fixture role.
@@ -1025,10 +1050,13 @@ async function assertReplaySession(
export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise {
const agent = scaffold.ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
+ const manifestPath = join(dirname(fixturePath), 'snapshot.yml')
+ const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
+ if (manifest.session !== undefined) return
const target = recordedSessionFixturePath(fixturePath, agent.session.header.version)
const existingPath = existsSync(target) ? target : fixturePath
const existing = existsSync(existingPath) ? await readFile(existingPath, 'utf8') : ''
- await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd))
+ await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd, existingPath))
}
/**
@@ -1098,20 +1126,48 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i
* Parse a committed web seed fixture through the replay reader.
* @param fixtureText - session JSONL fixture contents.
* @param fixturePath - exact source path for the closed replay-only refusal policy.
- * @returns the original header line, parsed header, and logical events.
+ * @returns the current header line, parsed header, and logical events.
*/
+/** Give a migrated fixture stream positive relative timing before its final wall-clock rebase. */
+function spreadMigratedSeedStream(
+ stream: SessionEvent<'assistant/message'>['data']['stream'],
+): SessionEvent<'assistant/message'>['data']['stream'] {
+ let nextTime = 0
+ return stream.map((record) => {
+ if ('time' in record) {
+ const timed = { ...record, time: nextTime }
+ nextTime += 1
+ return timed
+ }
+ const timed = { ...record, time0: nextTime }
+ nextTime += record.dt.reduce((total, delta) => total + delta, 0) + 1
+ return timed
+ })
+}
+
export function parseSeedFixture(fixtureText: string, fixturePath?: string): {
headerLine: string
header: Record
events: SessionEvent[]
} {
- const headerLine = fixtureText.split(/\r?\n/).find(line => line.trim().length > 0)
+ const sourceHeaderLine = fixtureText.split(/\r?\n/).find(line => line.trim().length > 0)
+ if (sourceHeaderLine === undefined) throw new Error('seed fixture has no session header')
+ const sourceHeader = JSON.parse(sourceHeaderLine) as { version?: unknown }
+ const current = prepareSessionSnapshotFixtureForComparison(fixtureText, fixturePath)
+ const headerLine = current.split(/\r?\n/).find(line => line.trim().length > 0)
if (headerLine === undefined) throw new Error('seed fixture has no session header')
const header = JSON.parse(headerLine) as Record
if (header.type !== 'session') throw new Error('seed fixture must start with a session header')
- const events = fixturePath === undefined
- ? parseSessionLog(fixtureText)
- : parseSessionLogForReplay(fixtureText, fixturePath)
+ const events = parseSessionLog(current).map((event) => {
+ if (sourceHeader.version === SESSION_FORMAT_VERSION) return event
+ if (event.type === 'assistant/message') {
+ return { ...event, data: { ...event.data, stream: spreadMigratedSeedStream(event.data.stream) } }
+ }
+ if (event.type === 'assistant/attempt') {
+ return { ...event, data: { ...event.data, stream: spreadMigratedSeedStream(event.data.stream) } }
+ }
+ return event
+ })
return { headerLine, header, events }
}
@@ -1132,6 +1188,34 @@ export function renderSeedFixture(
].join('\n')
}
+/** Re-anchor one projected embedded stream while preserving every intra-stream gap. */
+function rebaseSeedStream(
+ stream: SessionEvent<'assistant/message'>['data']['stream'],
+ startAt: number,
+): SessionEvent<'assistant/message'>['data']['stream'] {
+ const first = stream[0]
+ if (first === undefined) return stream
+ const sourceStart = 'time' in first ? first.time : first.time0
+ const delta = startAt - sourceStart
+ return stream.map(record => 'time' in record
+ ? { ...record, time: record.time + delta }
+ : { ...record, time0: record.time0 + delta })
+}
+
+/** Last logical timestamp carried by an embedded Assistant stream. */
+function seedStreamEnd(
+ stream: SessionEvent<'assistant/message'>['data']['stream'],
+): number | undefined {
+ let end: number | undefined
+ for (const record of stream) {
+ const recordEnd = 'time' in record
+ ? record.time
+ : record.time0 + record.dt.reduce((total, delta) => total + delta, 0)
+ end = end === undefined ? recordEnd : Math.max(end, recordEnd)
+ }
+ return end
+}
+
/**
* Seed a recorded session fixture into the scaffold's persistence root
* through the real Session and JSONL APIs. The source identity is consulted
@@ -1171,7 +1255,32 @@ export async function seedSession(
throw new Error('seed fixture requires a numeric createdAt header')
}
const timeAnchor = fixtureCreatedAt === 0 ? meta.createdAt : fixtureCreatedAt
- const materializedEvents = events.map((event, index) => ({ ...event, time: timeAnchor + index }))
+ let nextTime = timeAnchor
+ const materializedEvents: SessionEvent[] = events.map((event) => {
+ const time = nextTime
+ if (event.type === 'assistant/message') {
+ const stream = rebaseSeedStream(event.data.stream, time)
+ const completedAt = Math.max(time, seedStreamEnd(stream) ?? time)
+ nextTime = completedAt + 1
+ return {
+ ...event,
+ time: completedAt,
+ data: { ...event.data, stream },
+ }
+ }
+ if (event.type === 'assistant/attempt') {
+ const stream = rebaseSeedStream(event.data.stream, time)
+ const completedAt = Math.max(time, seedStreamEnd(stream) ?? time)
+ nextTime = completedAt + 1
+ return {
+ ...event,
+ time: completedAt,
+ data: { ...event.data, stream },
+ }
+ }
+ nextTime = time + 1
+ return { ...event, time }
+ })
await persistSeedSession(scaffold, meta, materializedEvents)
return meta.id
}
diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts
index 8d560114e5..f11b2b6efa 100644
--- a/apps/web/tests/schedule-after.e2e.ts
+++ b/apps/web/tests/schedule-after.e2e.ts
@@ -607,8 +607,6 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
const fixture = await readFile(CATALOG_FIXTURE, 'utf8')
scaffold = await launchWebScaffold({
extraOverlayPath: OVERLAY,
- replayFixture: CATALOG_FIXTURE,
- replayProvidersOnly: true,
})
await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard', CATALOG_FIXTURE)
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts
index a55958353d..42de0b5efe 100644
--- a/apps/web/tests/seeded-history.e2e.ts
+++ b/apps/web/tests/seeded-history.e2e.ts
@@ -148,7 +148,7 @@ function withCompaction(raw: string, meter: TokenMeter): string {
})
at({
type: 'user/message',
- data: {
+ data: createUserMessage({
content: [{
type: 'text',
text: 'Model-only compact checkpoint. ',
@@ -156,7 +156,7 @@ function withCompaction(raw: string, meter: TokenMeter): string {
source: {
kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
},
- },
+ }),
surfaceOp: { op: 'replace', start: first, end: last },
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
})
diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts
index 87981a5713..c15f253cdb 100644
--- a/apps/web/tests/steering.e2e.ts
+++ b/apps/web/tests/steering.e2e.ts
@@ -10,6 +10,7 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
import {
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden, fixtureUserPrompts,
@@ -52,11 +53,10 @@ const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.'
/** Concatenated assistant text deltas — the model-visible reply body. */
function assistantText(events: SessionEvent[]): string {
return events
- .filter(e => e.type === 'assistant/chunk')
- .map((e) => {
- const chunk = (e as SessionEvent & { data: { chunk: { type: string; text?: string } } }).data.chunk
- return chunk.type === 'text-delta' ? chunk.text ?? '' : ''
- })
+ .flatMap(e => e.type === 'assistant/message' || e.type === 'assistant/attempt'
+ ? expandAssistantStream(e.data.stream)
+ : [])
+ .map(({ chunk }) => chunk.type === 'text-delta' ? chunk.text : '')
.join('')
}
diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts
index 56b96adc4c..5e63c81ef5 100644
--- a/apps/web/tests/subagent-conversation.e2e.ts
+++ b/apps/web/tests/subagent-conversation.e2e.ts
@@ -6,6 +6,7 @@ import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { MessageId } from '@deepseek-ai/dsh-llm'
+import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay'
import {
SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq, type SessionEvent, type SessionHeader, type SessionId,
} from '@deepseek-ai/dsh-session'
@@ -14,7 +15,7 @@ import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import {
acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden,
- launchWebScaffold, watchConsole,
+ launchWebScaffold, selectedSessionFixture, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -44,14 +45,28 @@ const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.'
function childFixture(source: string, fixtureId: string, withContinuation: boolean): string {
const [header, ...eventLines] = source.trimEnd().split('\n')
if (header === undefined) throw new Error('base replay fixture has no header')
- const childHeader = header
- .replace('"id":"{{sessionId}}"', `"id":"${fixtureId}"`)
- .replace(/"createdAt":\d+/, '"createdAt":1784998084442')
+ const childHeaderValue = JSON.parse(header) as Record
+ childHeaderValue.id = fixtureId
+ childHeaderValue.createdAt = 1784998084442
+ const childHeader = JSON.stringify(childHeaderValue)
if (!withContinuation) return [childHeader, ...eventLines, ''].join('\n')
- const continued = eventLines.map(line => line
- .replace(/"seq":(\d+)/g, (_match, seq: string) => `"seq":${String(Number(seq) + 100)}`)
- .replace(/"seq0":(\d+)/g, (_match, seq: string) => `"seq0":${String(Number(seq) + 100)}`)
- .replaceAll('"turn":1', '"turn":2'))
+ const seqOffset = eventLines.length
+ const continued = eventLines.map((line) => {
+ const event = JSON.parse(line) as {
+ type: string
+ seq: number
+ data: Record
+ }
+ const data = { ...event.data }
+ if (data.turn === 1) data.turn = 2
+ if (event.type === 'session/title' && Array.isArray(data.messageSeqs)) {
+ data.messageSeqs = data.messageSeqs.map((seq: unknown) => {
+ if (typeof seq !== 'number') throw new Error('base replay fixture title has a non-numeric message seq')
+ return seq + seqOffset
+ })
+ }
+ return JSON.stringify({ ...event, seq: event.seq + seqOffset, data })
+ })
return [childHeader, ...eventLines, ...continued, ''].join('\n')
}
@@ -89,12 +104,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
beforeAll(async () => {
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
- const baseFixture = await readFile(BASE_FIXTURE, 'utf8')
+ const selectedBaseFixture = await selectedSessionFixture(BASE_FIXTURE)
+ const baseFixture = prepareSessionSnapshotFixtureForComparison(
+ await readFile(selectedBaseFixture, 'utf8'),
+ selectedBaseFixture,
+ )
sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-'))
const childFixturePath = join(sidecarRoot, 'child.jsonl')
await writeFile(childFixturePath, childFixture(baseFixture, 'recorded-subagent', true))
scaffold = await launchWebScaffold({
- replayFixture: BASE_FIXTURE,
+ replayFixture: selectedBaseFixture,
compareReplaySession: false,
replayChildFixtures: [childFixturePath],
paceMs: 25,
diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts
index 062fcc19c8..183ffa3a56 100644
--- a/apps/web/tests/trajectory-virtualization.e2e.ts
+++ b/apps/web/tests/trajectory-virtualization.e2e.ts
@@ -10,6 +10,8 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
+import { sessionFixtureName } from '@deepseek-ai/dsh-session-snapshot'
+import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
captureStableAria,
@@ -180,7 +182,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-trajectory-virtualization-'))
- const replayFixture = join(replayDir, 'session.jsonl')
+ const replayFixture = join(replayDir, sessionFixtureName(0, SESSION_FORMAT_VERSION))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayFixture, FIXTURE.log)
await writeFile(replayOverride, JSON.stringify([{
diff --git a/docs/agent-lifecycle.i18n.yaml b/docs/agent-lifecycle.i18n.yaml
index 7e3e47b3ac..a1021428ec 100644
--- a/docs/agent-lifecycle.i18n.yaml
+++ b/docs/agent-lifecycle.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/agent-lifecycle.md
-agent-lifecycle.md: 9d1b66888e35d840c95ee9f2bd589dad3aac66f6
-agent-lifecycle.zh.md: f1648792fa15495f878ae2ec362bb760ccf2dc22
+agent-lifecycle.md: 235bcec04356ceb238894c80e352f4460dbe18cb
+agent-lifecycle.zh.md: 687321375a85ae2e4901696f3a8b9c744dee221e
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 9d1b66888e..235bcec043 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -35,14 +35,16 @@ sequenceDiagram
Driver->>Prompt: system-prompt/assemble waterfall
Driver->>LLM: agent/request waterfall, then llm/stream waterfall
LLM-->>Driver: StreamChunk*
- Driver->>Session: assistant/chunk*
- Session-->>SDK: session/event assistant/chunk*
+ Driver-->>SDK: agent/assistant-stream chunk*
alt final adapter or terminal in-band request failure
+ Driver->>Session: assistant/attempt
+ Driver-->>SDK: agent/assistant-stream committed end
Driver->>Session: step/end
Driver->>Hooks: agent/request-error waterfall
Hooks-->>Driver: return retry action or preserve the original error
else model request succeeded
Driver->>Session: assistant/message
+ Driver-->>SDK: agent/assistant-stream committed end
Driver->>Tools: classify pending call by executionMode
loop barriers and bounded rolling pool, reclassify before start
opt call starts
@@ -71,7 +73,7 @@ sequenceDiagram
Driver-->>SDK: agent/status idle
```
-The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history, while the durable event keeps usage and `sourceEventSeqs` listing the exact `assistant/chunk` events, including an explicit empty list.
+The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes, and embeds the exact compact timed stream. Empty content stays out of derived history. A failed, retried, cancelled, or crash-tail attempt that commits no surface message records its stream as `assistant/attempt`. Live `agent/assistant-stream` chunk frames are transient; replay reads either durable settlement.
`dsh-compaction-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.
diff --git a/docs/agent-lifecycle.zh.md b/docs/agent-lifecycle.zh.md
index f1648792fa..687321375a 100644
--- a/docs/agent-lifecycle.zh.md
+++ b/docs/agent-lifecycle.zh.md
@@ -37,14 +37,16 @@ sequenceDiagram
Driver->>Prompt: system-prompt/assemble waterfall
Driver->>LLM: agent/request waterfall, then llm/stream waterfall
LLM-->>Driver: StreamChunk*
- Driver->>Session: assistant/chunk*
- Session-->>SDK: session/event assistant/chunk*
+ Driver-->>SDK: agent/assistant-stream chunk*
alt final adapter or terminal in-band request failure
+ Driver->>Session: assistant/attempt
+ Driver-->>SDK: agent/assistant-stream committed end
Driver->>Session: step/end
Driver->>Hooks: agent/request-error waterfall
Hooks-->>Driver: return retry action or preserve the original error
else model request succeeded
Driver->>Session: assistant/message
+ Driver-->>SDK: agent/assistant-stream committed end
Driver->>Tools: classify pending call by executionMode
loop barriers and bounded rolling pool, reclassify before start
opt call starts
@@ -73,7 +75,7 @@ sequenceDiagram
Driver-->>SDK: agent/status idle
```
-`assistant/message` 事件会记录每次成功的提供方调用,包括返回空内容或以 `max-tokens` 结束的调用。空内容不会进入派生历史,但该持久事件仍会保留用量,并通过 `sourceEventSeqs` 精确列出对应的 `assistant/chunk` 事件,包括显式空列表。
+`assistant/message` 事件会记录每次成功的提供方调用,包括返回空内容或以 `max-tokens` 结束的调用,并嵌入精确的紧凑带时间 stream。空内容不会进入派生历史。失败、重试、取消或崩溃尾部 attempt 若没有提交 surface message,会把 stream 记录为 `assistant/attempt`。实时 `agent/assistant-stream` chunk frame 是瞬态数据;回放读取任一种持久 settlement。
`dsh-compaction-basic` 在派生请求之前通过 `agent/pre-step` 处理压力,而 `agent/request-error` 仅用于规范的上下文溢出。任一触发条件满足后,系统都会先执行可选的工具结果剪枝,再选择摘要。恢复发生在失败步骤结束之后、失败轮次结束之前;只有当剪枝或摘要生成推进了 surface replacement generation 时,系统才会开启一个全新的重试轮次,否则仍以原始请求错误为准。
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 980acf927b..6b1b8b5fae 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 35a4c6fdf7240033dfd0781b5eb276b0c7318e45
-architecture.zh.md: 1c7dae640f526fddfd4c004fbaa847c957214b73
+architecture.md: 07b9d64707c521f9a8c4da67dfef15d3fa83d6d0
+architecture.zh.md: ee8cd9d7127332a14c39b8579db0c309a1ebc422
diff --git a/docs/architecture.md b/docs/architecture.md
index 35a4c6fdf7..07b9d64707 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -85,8 +85,8 @@ turn/start
append entered messages as user/message
derive model history from the log
agent/request -> llm/stream -> agent/assistant-stream start
- (assistant/chunk -> agent/assistant-stream chunk)*
- assistant/message -> agent/assistant-stream end
+ agent/assistant-stream chunk*
+ assistant/message | assistant/attempt -> agent/assistant-stream end
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
step/end
tools owe another request, or next-step input arrived -> claim -> next step
@@ -94,7 +94,7 @@ turn/start
turn/end
```
-`turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` is a process-local notification that follows each matching durable chunk and final message; the Web Session-follow adapter is its only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
+`turn/*`, `step/*`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
@@ -104,7 +104,7 @@ Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-ex
## Session log
-The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream.
+The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains failed, retried, cancelled, and crash-tail streams without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream` ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)).
Session consumers know only the current logical format. Header-only listing rescans each Session directory and classifies its numerically highest canonical generation without loading events. A cold body read selects that same highest generation, refuses a future version, or composes the static adjacent migration chain in memory, validates and repairs the final result, and exclusively publishes only that version-named successor beside the unchanged source; an already-validated current generation takes the fused no-write path and is cached for later same-process opens. JSONL v0 uses `session.jsonl[.zstd]`, v1 and later use lowercase `session.vN.jsonl[.zstd]`, and committed generation paths are never renamed, replaced, or deleted. The JSONL provider owns physical framing, compression, generation selection, and exclusive publication, while each adjacent migration package owns exactly one `vN -> vN+1` step ([decision](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 1c7dae640f..ee8cd9d712 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -89,8 +89,8 @@ turn/start
append entered messages as user/message
derive model history from the log
agent/request -> llm/stream -> agent/assistant-stream start
- (assistant/chunk -> agent/assistant-stream chunk)*
- assistant/message -> agent/assistant-stream end
+ agent/assistant-stream chunk*
+ assistant/message | assistant/attempt -> agent/assistant-stream end
tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
step/end
tools owe another request, or next-step input arrived -> claim -> next step
@@ -98,7 +98,7 @@ turn/start
turn/end
```
-`turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 是进程本地通知,跟随每个匹配的持久 chunk 和最终 message;Web Session-follow adapter 是它唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
+`turn/*`、`step/*`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
@@ -108,7 +108,7 @@ turn/end
## 会话日志
-会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流。
+会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史。每个 `assistant/message` 都嵌入产生其组装内容的精确紧凑带时间 stream;`assistant/attempt` 保留失败、重试、取消与崩溃尾部 stream,且不添加模型历史。fork、恢复、transcript(文本记录)、遥测与持久化都从这些持久 settlement 派生,实时 UI 增量则来自 `agent/assistant-stream`(见[决策](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md))。
Session 消费方只了解当前逻辑格式。仅 header 的列表会重新扫描每个 Session 目录,在不加载事件的情况下分类数值最高的规范 generation。冷正文读取选择同一个最高 generation,并拒绝未来版本;对于受支持的历史版本,它会在内存中组合静态相邻迁移链,校验并修复最终结果,再以不覆盖方式只发布该具名版本的后继文件,保持源文件不变。已经校验的当前 generation 采用融合的无写入路径,并缓存给同一进程的后续打开。JSONL v0 使用 `session.jsonl[.zstd]`,v1 及后续版本使用小写 `session.vN.jsonl[.zstd]`;已提交 generation 路径绝不重命名、替换或删除。JSONL provider 负责物理 framing、压缩、generation 选择与排他发布,每个相邻迁移包只负责一个 `vN -> vN+1` 步骤([决策](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 8ad05e1f69..8baf75f4d5 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 7e522b215e748b393b0466b32982fa69a1d56c6e
-config-catalog.zh.md: ccaca4ce39a0124b85535b1e6fcfeb53e67dff14
+config-catalog.md: de33644014aadacad338f38dec84af1bf08dafb2
+config-catalog.zh.md: 41de22e064219292fecbc851d95f80a48125d472
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 7e522b215e..de33644014 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1355,7 +1355,7 @@ export interface ReplayModelConfig {
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-Source: [`packages/test-support/llm-replay/src/index.ts:1185`](../packages/test-support/llm-replay/src/index.ts)
+Source: [`packages/test-support/llm-replay/src/index.ts:1388`](../packages/test-support/llm-replay/src/index.ts)
@@ -1828,7 +1828,7 @@ Source: [`packages/session-query/session-log-export/src/index.ts:42`](../package
Requires: `sessions`
```ts config-catalog
-/** Plugin config: where the JSONL backend keeps its session logs, and the packed-row write switch. */
+/** Plugin config for the JSONL backend's root, encoding, cache, and write batching. */
export interface Config {
/**
* Root directory for all session files. Required (no default): a default of
@@ -1838,14 +1838,6 @@ export interface Config {
* readable directory; an absent root is created on first materialization.
*/
root: string
- /**
- * 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). 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. */
compression?: JsonlCompression
/** Maximum cold Session preparations retained for history-to-resume reuse. */
@@ -1858,7 +1850,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:89`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:88`](../packages/session/session-persistence-jsonl/src/index.ts)
@@ -3469,6 +3461,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-session-format` ([`packages/session/session-format/src/index.ts`](../packages/session/session-format/src/index.ts))
- `@deepseek-ai/dsh-session-format-catalog` ([`packages/session/session-format-catalog/src/index.ts`](../packages/session/session-format-catalog/src/index.ts))
- `@deepseek-ai/dsh-session-format-v0-to-v1` ([`packages/session/session-format-v0-to-v1/src/index.ts`](../packages/session/session-format-v0-to-v1/src/index.ts))
+- `@deepseek-ai/dsh-session-format-v1-to-v2` ([`packages/session/session-format-v1-to-v2/src/index.ts`](../packages/session/session-format-v1-to-v2/src/index.ts))
- `@deepseek-ai/dsh-session-snapshot` ([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts))
- `@deepseek-ai/dsh-session-telemetry` ([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts))
- `@deepseek-ai/dsh-session-title-llm` ([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts))
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index ccaca4ce39..41de22e064 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1357,7 +1357,7 @@ export interface ReplayModelConfig {
依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-来源:[`packages/test-support/llm-replay/src/index.ts:1082`](../packages/test-support/llm-replay/src/index.ts)
+来源:[`packages/test-support/llm-replay/src/index.ts:1388`](../packages/test-support/llm-replay/src/index.ts)
@@ -1830,7 +1830,7 @@ export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
需要:`sessions` · `sessionProjections`
```ts config-catalog
-/** Plugin config: where the JSONL backend keeps its session logs, and the packed-row write switch. */
+/** Plugin config for the JSONL backend's root, encoding, cache, and write batching. */
export interface Config {
/**
* Root directory for all session files. Required (no default): a default of
@@ -1840,14 +1840,6 @@ export interface Config {
* readable directory; an absent root is created on first materialization.
*/
root: string
- /**
- * 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). 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. */
compression?: JsonlCompression
/** Maximum cold Session preparations retained for history-to-resume reuse. */
@@ -3470,6 +3462,7 @@ export interface Config {
- `@deepseek-ai/dsh-session-format`([`packages/session/session-format/src/index.ts`](../packages/session/session-format/src/index.ts))
- `@deepseek-ai/dsh-session-format-catalog`([`packages/session/session-format-catalog/src/index.ts`](../packages/session/session-format-catalog/src/index.ts))
- `@deepseek-ai/dsh-session-format-v0-to-v1`([`packages/session/session-format-v0-to-v1/src/index.ts`](../packages/session/session-format-v0-to-v1/src/index.ts))
+- `@deepseek-ai/dsh-session-format-v1-to-v2`([`packages/session/session-format-v1-to-v2/src/index.ts`](../packages/session/session-format-v1-to-v2/src/index.ts))
- `@deepseek-ai/dsh-session-snapshot`([`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts))
- `@deepseek-ai/dsh-session-telemetry`([`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts))
- `@deepseek-ai/dsh-session-title-llm`([`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts))
diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml
index 592540b3b7..d94e0a3d90 100644
--- a/docs/cookbook/extension-cookbook.i18n.yaml
+++ b/docs/cookbook/extension-cookbook.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md
-extension-cookbook.md: 0bbd1d0d531de755708da6c7a68b312675ba4b52
-extension-cookbook.zh.md: 5067c57bef08af65102e011f6d4c4394e03a3f5f
+extension-cookbook.md: b7f0f7939797d7c9929a87427eb624b7cfcc7a87
+extension-cookbook.zh.md: 6813a41baad4488123a2ad92672fdc275016d98b
diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md
index 0bbd1d0d53..b7f0f79397 100644
--- a/docs/cookbook/extension-cookbook.md
+++ b/docs/cookbook/extension-cookbook.md
@@ -34,7 +34,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an
## A UI plugin
-A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation subsystem reference](../subsystems/conversation.md).
+A UI plugin combines durable `session/event` records (Assistant settlements, turn/step boundaries, and tool activity) with transient `agent/assistant-stream` frames for live token presentation, and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation subsystem reference](../subsystems/conversation.md).
```ts
import type { Context } from '@deepseek-ai/cordis'
@@ -49,9 +49,9 @@ export const name = 'my-ui'
export const inject = ['agents']
export function apply(ctx: Context) {
- ctx.on('session/event', (_session, event) => {
- if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
- render(event.data.chunk.text)
+ ctx.on('agent/assistant-stream', ({ frame }) => {
+ if (frame.type === 'chunk' && frame.chunk.type === 'text-delta') {
+ render(frame.chunk.text)
}
})
onUserInput(text => ctx.agents.get(brandString('client-session'))?.followup(createUserMessage({
@@ -69,17 +69,19 @@ A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an
```ts
import type { Context } from '@deepseek-ai/cordis'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
export const name = 'my-protocol-bridge'
export const inject = ['agents', 'sessions', 'sessionPersistence']
export function apply(ctx: Context) {
- // Stream every logged assistant text/reasoning delta out to the client.
+ // Publish every committed Assistant text delta to the client.
ctx.on('session/event', (_session, event) => {
- if (event.type === 'assistant/chunk') {
- const chunk = event.data.chunk
- if (chunk.type === 'text-delta') {
- // sendToClient({ kind: 'message_chunk', text: chunk.text })
+ if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
+ for (const { chunk } of expandAssistantStream(event.data.stream)) {
+ if (chunk.type === 'text-delta') {
+ // sendToClient({ kind: 'message_chunk', text: chunk.text })
+ }
}
}
})
@@ -123,7 +125,7 @@ Every product feature maps to a listener on a documented extension point — the
| Skills | section + tool registration; `inject()` skill content on invocation |
| Memory | section provider + tool |
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'plugin', plugin: 'schedule'}})` when idle / `inject()` notification when busy |
-| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` |
+| UI (GUI; CLI emits JSONL) | listen to `agent/assistant-stream` for live chunks and `session/event` for durable settlements, boundaries, and tool activity; input → `followup()` |
| Web Client Chat business node | register a `ConversationNodeDefinition` and `conversation.chat.node` keyed renderer |
| SessionTelemetryBackend / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
| Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) |
diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md
index 5067c57bef..6813a41baa 100644
--- a/docs/cookbook/extension-cookbook.zh.md
+++ b/docs/cookbook/extension-cookbook.zh.md
@@ -36,7 +36,7 @@ export function apply(ctx: Context) {
## UI 插件
-UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体约定见 [Conversation 子系统参考](../subsystems/conversation.zh.md)。
+UI 插件把持久 `session/event` record(Assistant settlement、轮次/步骤边界与工具活动)和用于实时 token 呈现的瞬态 `agent/assistant-stream` frame 组合起来,并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体约定见 [Conversation 子系统参考](../subsystems/conversation.zh.md)。
```ts
import type { Context } from '@deepseek-ai/cordis'
@@ -51,9 +51,9 @@ export const name = 'my-ui'
export const inject = ['agents']
export function apply(ctx: Context) {
- ctx.on('session/event', (_session, event) => {
- if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
- render(event.data.chunk.text)
+ ctx.on('agent/assistant-stream', ({ frame }) => {
+ if (frame.type === 'chunk' && frame.chunk.type === 'text-delta') {
+ render(frame.chunk.text)
}
})
onUserInput(text => ctx.agents.get(brandString('client-session'))?.followup(createUserMessage({
@@ -71,17 +71,19 @@ export function apply(ctx: Context) {
```ts
import type { Context } from '@deepseek-ai/cordis'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
export const name = 'my-protocol-bridge'
export const inject = ['agents', 'sessions', 'sessionPersistence']
export function apply(ctx: Context) {
- // Stream every logged assistant text/reasoning delta out to the client.
+ // Publish every committed Assistant text delta to the client.
ctx.on('session/event', (_session, event) => {
- if (event.type === 'assistant/chunk') {
- const chunk = event.data.chunk
- if (chunk.type === 'text-delta') {
- // sendToClient({ kind: 'message_chunk', text: chunk.text })
+ if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
+ for (const { chunk } of expandAssistantStream(event.data.stream)) {
+ if (chunk.type === 'text-delta') {
+ // sendToClient({ kind: 'message_chunk', text: chunk.text })
+ }
}
}
})
@@ -127,7 +129,7 @@ export function apply(ctx: Context) {
| skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
| 记忆 | section 提供方 + 工具 |
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'plugin', plugin: 'schedule'}})`/忙碌时 `inject()` 通知 |
-| UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` |
+| UI(GUI;CLI(命令行界面)输出 JSONL) | 监听 `agent/assistant-stream` 的实时 chunk,并监听 `session/event` 的持久 settlement、边界与工具活动;输入 → `followup()` |
| Web Client Chat 业务节点 | 注册 `ConversationNodeDefinition` 与 `conversation.chat.node` keyed renderer |
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) |
diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml
index 92b49b9949..b004400ad7 100644
--- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml
+++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md
-deepseek-llm-api-wire-extensions.md: ba4573a73a289fc8af8ecf120a44c802476593ae
-deepseek-llm-api-wire-extensions.zh.md: 6d0a444796215eb723fad49d155e39f323ccd494
+deepseek-llm-api-wire-extensions.md: 678661ad1794a76e9e872e041415bb2f0f4db682
+deepseek-llm-api-wire-extensions.zh.md: 83b99d957a20fe3068f862c31faf499f6767acae
diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md
index ba4573a73a..678661ad17 100644
--- a/docs/deepseek-llm-api-wire-extensions.md
+++ b/docs/deepseek-llm-api-wire-extensions.md
@@ -79,11 +79,12 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
{
"dsh_session_log": {
"version": 1,
- "sessionFormatVersion": 1,
+ "sessionFormatVersion": 2,
"session": {
- "version": 1,
+ "version": 2,
"id": "session-id",
- "createdAt": 1780000000000
+ "createdAt": 1780000000000,
+ "isSeeded": false
},
"afterSeq": -1,
"throughSeq": 0,
@@ -105,7 +106,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
|---|---|---|
| `version` | `1` | Schema version for `dsh_session_log` |
| `sessionFormatVersion` | non-negative integer | Session format generation represented by this suffix |
-| `session` | object | Immutable wire projection of the current Session header and inherited cut |
+| `session` | object | Immutable wire projection of the current Session header |
| `afterSeq` | integer | Greatest sequence recorded as accepted before this request, or `-1` |
| `throughSeq` | non-negative integer | Greatest sequence represented by this request |
| `events` | array | Contiguous events from `afterSeq + 1` through `throughSeq` |
@@ -114,16 +115,16 @@ The first upload uses `afterSeq: -1` and carries the complete current log. Each
### Wire Session header
-The `session` member projects `Session.header`, not a complete runtime Session or the header object itself. It copies the current header facts and replaces `isSeeded` with numeric `seedLength` derived from the exact `Session.inheritedEventCount`. The outer `dsh_session_log.version` selects this extension schema, while `session.version` selects the logical Session format; the two version values evolve independently.
+The `session` member projects `Session.header`, not a complete runtime Session or the header object itself. It copies the current header facts, including the required `isSeeded` lineage bit; the exact `Session.inheritedEventCount` is not part of this request field. The outer `dsh_session_log.version` selects this extension schema, while `session.version` selects the logical Session format; the two version values evolve independently.
| Member | Presence | Meaning |
|---|---|---|
-| `version` | required | Logical Session format version; currently `1` |
+| `version` | required | Logical Session format version; currently `2` |
| `id` | required | Exact Session id |
| `createdAt` | required | Non-negative safe-integer Unix epoch milliseconds |
| `cwd` | optional | Absolute working directory recorded at Session creation |
| `parentSession` | optional | Parent Session id for a fork |
-| `seedLength` | optional | Number of leading events inherited through the seed |
+| `isSeeded` | required | Whether the Session contains a fork-inherited event prefix |
| `origin` | optional | Literal `subagent` for a subagent child |
| `delegationDepth` | optional | Non-negative persisted subagent delegation depth |
| `agentPreset` | optional | Agent preset id used to compose this Session |
@@ -143,7 +144,7 @@ After the endpoint returns HTTP 2xx, the contribution appends this canonical eve
"time": 1780000000002,
"data": {
"sessionId": "session-id",
- "sessionFormatVersion": 1,
+ "sessionFormatVersion": 2,
"throughSeq": 7
}
}
@@ -151,12 +152,12 @@ After the endpoint returns HTTP 2xx, the contribution appends this canonical eve
`delivery-accepted` means that the configured endpoint returned HTTP 2xx for the containing LLM request. It does not assert SSE completion or remote persistence. The event's `throughSeq` must identify an earlier event, its `sessionId` identifies the Session whose suffix was sent, and `sessionFormatVersion` binds the watermark to that exact logical generation. Absence means historical format v0.
-The sender folds the greatest matching `throughSeq` for the current Session id and format generation, so concurrent accepted requests cannot move the cursor backward and a migrated v0 watermark cannot authorize a v1 suffix. A resumed process rebuilds the cursor from the durable log. A fork ignores inherited watermarks that name another Session, and therefore sends its own complete inherited prefix before advancing under the child id. The watermark event itself belongs to the next unsent suffix.
+The sender folds the greatest matching `throughSeq` for the current Session id and format generation, so concurrent accepted requests cannot move the cursor backward and a watermark from another generation cannot authorize the current suffix. A resumed process rebuilds the cursor from the durable log. A fork ignores inherited watermarks that name another Session, and therefore sends its own complete inherited prefix before advancing under the child id. The watermark event itself belongs to the next unsent suffix.
Transport and non-2xx failures append no watermark. A crash after endpoint acceptance but before local persistence may resend an already accepted range; uncertainty produces duplicates, never a sequence gap. There is no independent upload store, size cap, or truncation path.
## Exposure and receiver requirements
-The request headers expose the Harness application version, one anonymous Harness-home identity, and an optional Session identity. `dsh_plugin_packages` exposes active npm package names and versions. When enabled, `dsh_session_log` may expose the Session working directory, system-prompt snapshots, user and assistant content, raw assistant chunks, tool arguments and results, compaction summaries, feedback, and plugin-owned events. Adapter API keys are not Session events and therefore do not enter the field. A gateway selected through `baseURL` receives the same values as the official endpoint.
+The request headers expose the Harness application version, one anonymous Harness-home identity, and an optional Session identity. `dsh_plugin_packages` exposes active npm package names and versions. When enabled, `dsh_session_log` may expose the Session working directory, system-prompt snapshots, user and Assistant content, embedded Assistant streams, failed-attempt output, tool arguments and results, compaction summaries, feedback, and plugin-owned events. Adapter API keys are not Session events and therefore do not enter the field. A gateway selected through `baseURL` receives the same values as the official endpoint.
Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. A session-log receiver validates the contiguous sequence range before interpreting event types. An unrecognized canonical event without `ignorable: true` prevents lossless reconstruction. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request.
diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md
index 6d0a444796..83b99d957a 100644
--- a/docs/deepseek-llm-api-wire-extensions.zh.md
+++ b/docs/deepseek-llm-api-wire-extensions.zh.md
@@ -79,11 +79,12 @@
{
"dsh_session_log": {
"version": 1,
- "sessionFormatVersion": 1,
+ "sessionFormatVersion": 2,
"session": {
- "version": 1,
+ "version": 2,
"id": "session-id",
- "createdAt": 1780000000000
+ "createdAt": 1780000000000,
+ "isSeeded": false
},
"afterSeq": -1,
"throughSeq": 0,
@@ -105,7 +106,7 @@
|---|---|---|
| `version` | `1` | `dsh_session_log` 的 schema 版本 |
| `sessionFormatVersion` | 非负整数 | 该后缀所表示的 Session 格式 generation |
-| `session` | 对象 | 当前 Session header 与继承切点的不可变协议投影 |
+| `session` | 对象 | 当前 Session header 的不可变协议投影 |
| `afterSeq` | 整数 | 本次请求前记录为已接受的最大序号,或 `-1` |
| `throughSeq` | 非负整数 | 本次请求所表示的最大序号 |
| `events` | 数组 | 从 `afterSeq + 1` 到 `throughSeq` 的连续事件 |
@@ -114,16 +115,16 @@
### Session 协议 header
-`session` 成员投影 `Session.header`,既不是完整的运行时 Session,也不是 header 对象本身。它复制当前 header 事实,并把 `isSeeded` 替换为根据精确 `Session.inheritedEventCount` 得出的数值 `seedLength`。外层 `dsh_session_log.version` 选择本扩展 schema,`session.version` 则选择逻辑 Session 格式;两个版本值相互独立演进。
+`session` 成员投影 `Session.header`,既不是完整的运行时 Session,也不是 header 对象本身。它复制当前 header 事实,包括必需的 `isSeeded` 谱系位;精确的 `Session.inheritedEventCount` 不属于该请求字段。外层 `dsh_session_log.version` 选择本扩展 schema,`session.version` 则选择逻辑 Session 格式;两个版本值相互独立演进。
| 成员 | 出现条件 | 含义 |
|---|---|---|
-| `version` | 必需 | 逻辑 Session 格式版本;当前为 `1` |
+| `version` | 必需 | 逻辑 Session 格式版本;当前为 `2` |
| `id` | 必需 | 确切的会话 id |
| `createdAt` | 必需 | 非负安全整数 Unix epoch 毫秒数 |
| `cwd` | 可选 | 创建会话时记录的绝对工作目录 |
| `parentSession` | 可选 | fork 的父会话 id |
-| `seedLength` | 可选 | 通过 seed 继承的前导事件数量 |
+| `isSeeded` | 必需 | Session 是否包含 fork 继承的事件前缀 |
| `origin` | 可选 | subagent 子项使用的字面值 `subagent` |
| `delegationDepth` | 可选 | 持久化的非负 subagent 委派深度 |
| `agentPreset` | 可选 | 用于组合该会话的 agent preset id |
@@ -143,7 +144,7 @@
"time": 1780000000002,
"data": {
"sessionId": "session-id",
- "sessionFormatVersion": 1,
+ "sessionFormatVersion": 2,
"throughSeq": 7
}
}
@@ -151,12 +152,12 @@
`delivery-accepted` 表示已配置端点为包含该字段的 LLM 请求返回 HTTP 2xx。它不表示 SSE 已完整结束,也不表示远端已经持久化。该事件的 `throughSeq` 必须标识一项更早的事件,`sessionId` 标识已发送后缀所属的 Session,`sessionFormatVersion` 则把水位绑定到该逻辑 generation。缺少该字段表示历史格式 v0。
-发送方只会为当前 Session id 与格式 generation 折叠最大的匹配 `throughSeq`,因此并发已接受请求无法使游标倒退,迁移后的 v0 水位也不能授权 v1 后缀。恢复后的进程会从持久日志重建游标。fork 会忽略命名其他 Session 的继承水位,因此先发送自身完整的继承前缀,再以子会话 id 推进。水位事件自身属于下一段未发送后缀。
+发送方只会为当前 Session id 与格式 generation 折叠最大的匹配 `throughSeq`,因此并发已接受请求无法使游标倒退,其他 generation 的水位也不能授权当前后缀。恢复后的进程会从持久日志重建游标。fork 会忽略命名其他 Session 的继承水位,因此先发送自身完整的继承前缀,再以子会话 id 推进。水位事件自身属于下一段未发送后缀。
传输失败和非 2xx 响应不会追加水位。端点接受后、本地持久化前发生崩溃时,系统可能重新发送已接受范围;不确定性只会产生重复,绝不会产生序号缺口。系统没有独立上传存储、大小上限或截断路径。
## 暴露内容与接收方要求
-请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。启用后,`dsh_session_log` 可能暴露会话工作目录、系统提示词快照、用户与 assistant 内容、原始 assistant 分片、工具参数与结果、压缩摘要、反馈和插件持有的事件。适配器 API key 不是会话事件,因此不会进入该字段。通过 `baseURL` 选择的网关会收到与官方端点相同的值。
+请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。启用后,`dsh_session_log` 可能暴露会话工作目录、系统提示词快照、用户与 Assistant 内容、嵌入式 Assistant stream、失败 attempt 输出、工具参数与结果、压缩摘要、反馈和插件持有的事件。适配器 API key 不是会话事件,因此不会进入该字段。通过 `baseURL` 选择的网关会收到与官方端点相同的值。
接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。遇到不带 `ignorable: true` 的未知权威事件时,接收方无法进行无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index c99d4053f5..62be0974c2 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: d15fb98bcde019710927a646aca36fb20d18e78e
-event-producer-consumer.zh.md: 8dbfc447f5b629d739e13b5a9eea9a8d7152529a
+event-producer-consumer.md: b710cb17a75ce3065ac87d1739d0b5974c63e93c
+event-producer-consumer.zh.md: d208f8828d815f3043fe5bf160b07c0c3fc27a59
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index d15fb98bcd..b710cb17a7 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -9,24 +9,24 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:240`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:313`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `session-controller` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:202`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:211`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:240`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:248`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:229`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:274`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:287`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:303`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:221`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:331`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:206`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:252`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:233`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:291`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:225`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:335`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -44,19 +44,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:173`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:147`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 8dbfc447f5..d208f8828d 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -11,24 +11,24 @@
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:240`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:313`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `session-controller` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:202`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:211`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:240`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:248`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:229`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:274`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:287`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:303`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:221`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:331`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:206`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:252`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:233`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:291`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:225`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:335`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -38,27 +38,27 @@
| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:398`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:368`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:374`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
-| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:96`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
-| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:84`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
+| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
+| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
-| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:173`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:147`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index a2a5e47205..e661e9c47f 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: e665a73a72c0282bcc7950c7c57553b7793241ee
-module-graph.zh.md: e4d03468060b2857e8826971ccdcbd6f45351a94
+module-graph.md: de630ca6cdb1da1efa07486db508e6790e6fd949
+module-graph.zh.md: 54e4f7893cb457254d866bc0f367b69db1a0aaab
diff --git a/docs/module-graph.md b/docs/module-graph.md
index e665a73a72..de630ca6cd 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -286,6 +286,7 @@ flowchart TD
pkg_session_format["session-format"]
pkg_session_format_catalog["session-format-catalog"]
pkg_session_format_v0_to_v1["session-format-v0-to-v1"]
+ pkg_session_format_v1_to_v2["session-format-v1-to-v2"]
pkg_session_log_deepseek["session-log-deepseek"]
pkg_session_persistence["session-persistence"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
@@ -1224,6 +1225,7 @@ flowchart TD
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
+| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
| [`storage`](../packages/storage/storage) | `storage` | — |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | — |
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index e4d0346806..54e4f7893c 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -288,6 +288,7 @@ flowchart TD
pkg_session_format["session-format"]
pkg_session_format_catalog["session-format-catalog"]
pkg_session_format_v0_to_v1["session-format-v0-to-v1"]
+ pkg_session_format_v1_to_v2["session-format-v1-to-v2"]
pkg_session_log_deepseek["session-log-deepseek"]
pkg_session_persistence["session-persistence"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
@@ -1226,6 +1227,7 @@ flowchart TD
| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
| [`session-format-v0-to-v1`](../packages/session/session-format-v0-to-v1) | `session` | — |
+| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
| [`storage`](../packages/storage/storage) | `storage` | — |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | — |
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index ad9d851a59..4786410ba6 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 94a2415f92d4fccdde96553372b2536c81744e24
-persistence-catalog.zh.md: 649530fb89de98dc7c649b00d2c5bed4d9da30e8
+persistence-catalog.md: 12e2fee2419262dba1214a14ab66cc2f57af3e41
+persistence-catalog.zh.md: d7f200b20fe2d6fd74d19b9c6cb8a3cfb55c08bf
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 94a2415f92..12e2fee241 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -18,7 +18,8 @@ export type SessionEventType = keyof SessionEventMap
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these
- * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
+ * event types may carry {@link SurfaceOp}; user and tool events may also cite
+ * earlier sources through {@link SessionEvent.sourceEventSeqs}.
*/
export type SurfaceEventType =
| 'user/message'
@@ -51,7 +52,7 @@ export type SurfaceOp =
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`).
- * Non-surface events (boundary markers, chunks, usage, errors) never carry
+ * Non-surface events (boundary markers, attempts, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
@@ -76,12 +77,9 @@ export type SessionEvent = {
ignorable?: true
} & (K extends SurfaceEventType ? {
/**
- * Seq numbers of earlier events that this event cites as sources
- * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
- * or the surface nodes shadowed by a compaction replace node). An
- * `assistant/message` may carry a present empty array for a known empty
- * provider stream; when the field is absent, the event does not record which
- * earlier events produced the message.
+ * Seq numbers of earlier events that this event cites as sources, such as
+ * the surface nodes shadowed by a compaction replacement. A v2
+ * `assistant/message` embeds its provider stream and cannot carry this field.
*/
sourceEventSeqs?: SessionSeq[]
/** How this event entered the surface; absent for non-surface events. */
@@ -90,7 +88,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:364`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:371`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:414`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:445`](../packages/core/session/src/types.ts)
## Events
@@ -204,18 +202,20 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter
### `assistant/*`
-
+
-#### `assistant/chunk` — log-only
+#### `assistant/attempt` — log-only
```ts persistence-catalog
-/** Raw stream chunk — token-level replay fidelity. */
-'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
+/**
+ * One model attempt that committed no surface message. The embedded stream
+ * preserves failed, retried, cancelled, or crash-tail output without
+ * fabricating model-visible history.
+ */
+'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
```
-Types: [StreamChunk](subsystems/llm-streaming.md)
-
-Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts)
@@ -232,12 +232,20 @@ Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/
* marker distinguishes that prefix without re-deriving interruption from turn
* boundaries. An aborted turn with no such event streamed no visible content.
*/
-'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
+'assistant/message': {
+ turn: number
+ step: number
+ message: AssistantMessage
+ /** Exact timed model stream, compacted without joining delta boundaries. */
+ stream: AssistantStreamRecord[]
+ usage?: TokenUsage
+ interrupted?: true
+}
```
Types: [TokenUsage](subsystems/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts)
### `command/*`
@@ -512,7 +520,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
'model/selection': ModelSelection
```
-Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts)
+Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts)
### `permission/*`
@@ -563,7 +571,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s
'request/context': RequestContext
```
-Source: [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts)
@@ -582,7 +590,7 @@ Source: [`packages/core/session/src/types.ts:337`](../packages/core/session/src/
}
```
-Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -636,12 +644,12 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
- * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
- * carry the meaning.
+ * {@link Session.firstLiveSeq}.
*
- * Locate the LAST one in stored history. A seed already ending in one is not
- * re-marked, so reopening an untouched session does not grow its log per
- * pickup and the event need not be at the current `firstLiveSeq`.
+ * A fresh fork child owns one `{ inherited: true }` marker at its exact
+ * inherited-prefix cut, even when that prefix ends in an ancestor marker.
+ * The last tagged marker is the current Session's cut; untagged markers keep
+ * ordinary restore and replay lifecycle boundaries.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
@@ -654,10 +662,10 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
-'session/end-seed': Record
+'session/end-seed': { inherited?: true }
```
-Source: [`packages/core/session/src/types.ts:360`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:373`](../packages/core/session/src/types.ts)
@@ -719,7 +727,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:59`](../packages/se
'step/end': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
@@ -730,7 +738,7 @@ Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/
'step/start': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
### `subagent/*`
@@ -861,7 +869,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s
Types: [ToolCallId](subsystems/core.md)
-Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
@@ -936,7 +944,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
}
```
-Source: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts)
### `tool-workflow/*`
@@ -1016,7 +1024,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow
Types: [TurnEndReason](subsystems/session.md)
-Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts)
@@ -1032,7 +1040,7 @@ Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/
'turn/start': { turn: number }
```
-Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts)
### `user/*`
@@ -1051,7 +1059,7 @@ Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/
'user/message': UserMessage
```
-Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts)
### `web/*`
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index 649530fb89..d7f200b20f 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -20,7 +20,8 @@ export type SessionEventType = keyof SessionEventMap
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these
- * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
+ * event types may carry {@link SurfaceOp}; user and tool events may also cite
+ * earlier sources through {@link SessionEvent.sourceEventSeqs}.
*/
export type SurfaceEventType =
| 'user/message'
@@ -53,7 +54,7 @@ export type SurfaceOp =
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`).
- * Non-surface events (boundary markers, chunks, usage, errors) never carry
+ * Non-surface events (boundary markers, attempts, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
@@ -78,12 +79,9 @@ export type SessionEvent = {
ignorable?: true
} & (K extends SurfaceEventType ? {
/**
- * Seq numbers of earlier events that this event cites as sources
- * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
- * or the surface nodes shadowed by a compaction replace node). An
- * `assistant/message` may carry a present empty array for a known empty
- * provider stream; when the field is absent, the event does not record which
- * earlier events produced the message.
+ * Seq numbers of earlier events that this event cites as sources, such as
+ * the surface nodes shadowed by a compaction replacement. A v2
+ * `assistant/message` embeds its provider stream and cannot carry this field.
*/
sourceEventSeqs?: SessionSeq[]
/** How this event entered the surface; absent for non-surface events. */
@@ -92,7 +90,7 @@ export type SessionEvent = {
}[T]
```
-来源:[`packages/core/session/src/types.ts:366`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:373`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:385`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:414`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:445`](../packages/core/session/src/types.ts)
## 事件
@@ -206,18 +204,20 @@ export type SessionEvent = {
### `assistant/*`
-
+
-#### `assistant/chunk` — log-only
+#### `assistant/attempt` — log-only
```ts persistence-catalog
-/** Raw stream chunk — token-level replay fidelity. */
-'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
+/**
+ * One model attempt that committed no surface message. The embedded stream
+ * preserves failed, retried, cancelled, or crash-tail output without
+ * fabricating model-visible history.
+ */
+'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
```
-类型:[StreamChunk](subsystems/llm-streaming.zh.md)
-
-来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts)
@@ -234,12 +234,20 @@ export type SessionEvent = {
* marker distinguishes that prefix without re-deriving interruption from turn
* boundaries. An aborted turn with no such event streamed no visible content.
*/
-'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
+'assistant/message': {
+ turn: number
+ step: number
+ message: AssistantMessage
+ /** Exact timed model stream, compacted without joining delta boundaries. */
+ stream: AssistantStreamRecord[]
+ usage?: TokenUsage
+ interrupted?: true
+}
```
类型:[TokenUsage](subsystems/llm-streaming.zh.md)
-来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts)
### `command/*`
@@ -638,12 +646,12 @@ export type SessionEvent = {
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
- * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
- * carry the meaning.
+ * {@link Session.firstLiveSeq}.
*
- * Locate the LAST one in stored history. A seed already ending in one is not
- * re-marked, so reopening an untouched session does not grow its log per
- * pickup and the event need not be at the current `firstLiveSeq`.
+ * A fresh fork child owns one `{ inherited: true }` marker at its exact
+ * inherited-prefix cut, even when that prefix ends in an ancestor marker.
+ * The last tagged marker is the current Session's cut; untagged markers keep
+ * ordinary restore and replay lifecycle boundaries.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
@@ -656,10 +664,10 @@ export type SessionEvent = {
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
-'session/end-seed': Record
+'session/end-seed': { inherited?: true }
```
-来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:373`](../packages/core/session/src/types.ts)
diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml
index 68358c4f1e..e226ceae53 100644
--- a/docs/subsystems/conversation.i18n.yaml
+++ b/docs/subsystems/conversation.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/conversation.md
-conversation.md: df1476537b95690ae2055f367e8586653b99a9a9
-conversation.zh.md: 784f52975cbb829d8914ef630aa1041693e1de62
+conversation.md: 61e76e1b057be7b47150bd623b3e04400b50e394
+conversation.zh.md: 5b54625fcfe9ac451e79a42c387a5cf0836ee39a
diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md
index df1476537b..61e76e1b05 100644
--- a/docs/subsystems/conversation.md
+++ b/docs/subsystems/conversation.md
@@ -8,12 +8,12 @@ This page defines the data model and the extension path for a business-owned Con
## Data model and ownership
-The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. `ui-conversation` passes these entries to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
+The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` for one durable event or `{ type: 'transient', event: AssistantLiveChunkEvent }` for one Client-only `assistant/live-chunk` presentation. Both inner events expose `type`, `seq`, `time`, and `data`. `ui-conversation` passes these entries to the assembler without opening a second history stream. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target.
| Concept | Owner and purpose |
|---|---|
-| Event Definition | A business package matches one standard event or packed Assistant run at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. |
-| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A packed run occupies one update Match; update-only evidence may remain pending until pagination supplies its unique scalar start. |
+| Event Definition | A business package matches one durable or Client-only transient event at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. |
+| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. |
| Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. |
| View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. |
| View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. |
@@ -42,7 +42,7 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses
Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events.
-Historical runs of consecutive same-block `assistant/chunk` deltas arrive as `chunkrow/text-chunks`, `chunkrow/reasoning-chunks`, or `chunkrow/tool-call-chunks`. Their top-level `seq` and `time` identify the first logical member, and their `data` retains each fragment and timestamp gap. These Client-only events can only be updates; `start()` receives a standard `SessionEvent`. A Definition that consumes Assistant deltas handles the relevant packed tags in the same `match()` and `update()` methods, while other Definitions return `null` without expanding the run.
+Live Assistant deltas arrive as Client-only `assistant/live-chunk` updates. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. Transient events can only be updates; `start()` receives a standard `SessionEvent`. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream.
## Definition and typed Chat payload
diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md
index 784f52975c..5b54625fcf 100644
--- a/docs/subsystems/conversation.zh.md
+++ b/docs/subsystems/conversation.zh.md
@@ -8,12 +8,12 @@ Conversation 是 Client `SessionEventLikeEntry` window 与浏览器 view 之间
## 数据模型与所有权
-Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }` 或 `{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。`ui-conversation` 把这些 entry 直接交给 assembler,不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
+Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 要么是表示一个持久事件的 `{ type: 'event', event: SessionEvent }`,要么是表示一个 Client-only `assistant/live-chunk` 呈现的 `{ type: 'transient', event: AssistantLiveChunkEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。`ui-conversation` 把这些 entry 直接交给 assembler,不另开 history stream。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。
| 概念 | Owner 与用途 |
|---|---|
-| Event Definition | 业务包一次匹配一条标准 event 或一个 packed Assistant run,以稳定 `(kind, id)` 关联输入、折叠确定性 State,并可选择 materialize 一个 target node。 |
-| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个 packed run 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一 scalar start。 |
+| Event Definition | 业务包一次匹配一个持久 event 或 Client-only 瞬态 event,以稳定 `(kind, id)` 关联输入、折叠确定性 State,并可选择 materialize 一个 target node。 |
+| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 |
| Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 |
| View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 |
| View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 |
@@ -42,7 +42,7 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro
系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。
-连续且属于同一 block 的历史 `assistant/chunk` delta 会以 `chunkrow/text-chunks`、`chunkrow/reasoning-chunks` 或 `chunkrow/tool-call-chunks` 到达。顶层 `seq` 与 `time` 表示首个逻辑成员,`data` 保留每个 fragment 与 timestamp gap。这些 Client-only event 只能充当 update;`start()` 只接收标准 `SessionEvent`。消费 Assistant delta 的 Definition 在同一组 `match()` 与 `update()` 方法里处理相关 packed tag,其他 Definition 直接返回 `null`,无需展开该 run。
+实时 Assistant delta 作为 Client-only `assistant/live-chunk` update 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 只能充当 update;`start()` 只接收标准 `SessionEvent`。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。
## Definition 与类型化 Chat payload
diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml
index 5bb6a71b48..93ede00575 100644
--- a/docs/subsystems/core.i18n.yaml
+++ b/docs/subsystems/core.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: 81c73a0c7b3653a07646cc72e873cb1d137b515d
-core.zh.md: ae8c6c07db80ad54471e3bb8a34852dff5a15bc9
+core.md: 955db2e2bf0e413e399f6519ffeee12f81a173f4
+core.zh.md: 12ad7735117da8a2671051f8862a4817a36d0065
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index 81c73a0c7b..955db2e2bf 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -252,7 +252,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. Every entry carries a monotonic `seq`, a `time`, and a `type`-discriminated `data` payload; surface variants may also list cited earlier events in `sourceEventSeqs` and carry a `surfaceOp`.
-The `SessionEvent` envelope's exact conditional fields, the twelve core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/chunk`, `assistant/message`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
+The `SessionEvent` envelope's exact conditional fields, the twelve core event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/attempt`, `tool/call`, `tool/result`, `request/header`, `request/context`, `session/end-seed`), the `deriveMessages()` projection rules, the `TurnEndReason` reasons, and the execution-enclosure and standalone-event rules are on **[session.md](session.md)**. How the log is made durable — the `SessionPersistence` interface, JSONL provider, `session/flush` checkpoint, crash recovery, and `SessionHeader` — is on **[persistence.md](persistence.md)**.
## `ToolDefinition`
@@ -807,13 +807,13 @@ Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index
#### `agent/assistant-stream` — emit
-Process-local assistant-stream publication. The loop appends each v1 `assistant/chunk` before the matching chunk frame and appends the final `assistant/message` before a committed end frame.
+Process-local assistant-stream publication. Chunk frames are transient; the loop appends one final v2 `assistant/message` or `assistant/attempt` with the same stream before a committed end frame.
```ts cordis-catalog
/**
- * Process-local assistant-stream publication. The loop appends each v1
- * `assistant/chunk` before the matching chunk frame and appends the final
- * `assistant/message` before a committed end frame.
+ * Process-local assistant-stream publication. Chunk frames are transient;
+ * the loop appends one final v2 `assistant/message` or `assistant/attempt`
+ * with the same stream before a committed end frame.
* @param payload.agent - the agent whose attempt produced the frame.
* @param payload.frame - one ordered start, chunk, or end publication.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index ae8c6c07db..12ad773511 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -260,7 +260,7 @@ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
`Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM 消息历史从日志*派生*(`deriveMessages()`),而非单独存储。每个条目携带单调的 `seq`、`time` 与按 `type` 判别的 `data` payload;surface 变体还可以在 `sourceEventSeqs` 中列出被引用的较早事件,并携带 `surfaceOp`。
-`SessionEvent` 信封的确切条件字段、十二种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/chunk`、`assistant/message`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。
+`SessionEvent` 信封的确切条件字段、十二种核心事件变体(`turn/start`、`turn/end`、`step/start`、`step/end`、`user/message`、`assistant/message`、`assistant/attempt`、`tool/call`、`tool/result`、`request/header`、`request/context`、`session/end-seed`)、`deriveMessages()` 投影规则、`TurnEndReason` 原因以及执行封闭和独立事件规则都在 **[session.md](session.zh.md)** 中。日志如何持久化——`SessionPersistence` 接口、JSONL provider、`session/flush` 检查点、崩溃恢复与 `SessionHeader`——则在 **[persistence.md](persistence.zh.md)** 中。
## `ToolDefinition`
@@ -817,13 +817,13 @@ Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index
#### `agent/assistant-stream` — emit
-Process-local assistant-stream publication. The loop appends each v1 `assistant/chunk` before the matching chunk frame and appends the final `assistant/message` before a committed end frame.
+Process-local assistant-stream publication. Chunk frames are transient; the loop appends one final v2 `assistant/message` or `assistant/attempt` with the same stream before a committed end frame.
```ts cordis-catalog
/**
- * Process-local assistant-stream publication. The loop appends each v1
- * `assistant/chunk` before the matching chunk frame and appends the final
- * `assistant/message` before a committed end frame.
+ * Process-local assistant-stream publication. Chunk frames are transient;
+ * the loop appends one final v2 `assistant/message` or `assistant/attempt`
+ * with the same stream before a committed end frame.
* @param payload.agent - the agent whose attempt produced the frame.
* @param payload.frame - one ordered start, chunk, or end publication.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml
index ea90fbb43d..c0649d5acb 100644
--- a/docs/subsystems/llm-streaming.i18n.yaml
+++ b/docs/subsystems/llm-streaming.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: 6867ae292d77474bcedc1466ae0ce6b1fc1c92d3
-llm-streaming.zh.md: b75e24f2e9010b4fb08c035f14bc4e91dc3971ef
+llm-streaming.md: d0d83897db4924de885f012212a200fa7d88b36e
+llm-streaming.zh.md: 962cea4bf6ec5c4db84d7613c7a2317693bf5c47
diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md
index 6867ae292d..d0d83897db 100644
--- a/docs/subsystems/llm-streaming.md
+++ b/docs/subsystems/llm-streaming.md
@@ -216,6 +216,16 @@ type StreamChunk =
}
```
+
+
+## Compact Assistant streams
+
+`AssistantStreamAccumulator` pairs each `StreamChunk` with its original safe-integer timestamp and produces `AssistantStreamRecord[]`. Consecutive text, reasoning, or tool-argument deltas for the same block become one record with `time0`, exact timestamp gaps, and one array entry per original delta; every other chunk stays a timestamped raw record. This representation removes repeated event envelopes without joining token boundaries or dropping terminal, usage, block, failure, or replay facts.
+
+`snapshot()` returns a detached immutable stream. `expandAssistantStream()` strictly checks record keys, member counts, indexes, timestamps, tool-call identity, and lossless JSON before recreating the exact timed chunk sequence. The Session log embeds this stream in `assistant/message` for a surface result or `assistant/attempt` for an attempt with no surface message.
+
+Process-local `agent/assistant-stream` frames carry live presentation. Durable replay, telemetry, token accounting, and historical UI assembly expand the embedded settlement instead of treating live frames as persisted facts.
+
## `LlmFailure`
Every thrown or in-band final-adapter failure normalizes to one serializable provider-neutral payload. `providerRetryAfterMs` is a validated positive delay requested by the provider, not a retry decision; `ProviderRequestId` is an opaque branded string for diagnostics.
@@ -280,7 +290,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering.
- **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`.
-- **Two sanctioned error paths, one `LlmFailure` type.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. After the call selects its adapter, the stream preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop closes the failed step and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no normal assistant message or tool side effect is committed for that attempt.
+- **Two sanctioned error paths, one `LlmFailure` type.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted', failure}` (provider in-band errors, for adapters that can't throw mid-stream). `LlmError.failure` carries the same `LlmFailure`. After the call selects its adapter, the stream preserves the exact thrown `Error` object and associates immutable facts plus the serving registration's immutable retry policy with that call; the agent loop commits the attempt stream as `assistant/attempt`, closes the failed step, and offers the error, facts, immutable prior-retried facts, serving policy, and turn signal to `agent/request-error`. A handling listener returns `{ kind: 'retry' }` after its awaited repair; absent recovery the structured failure becomes the turn error, and no surface Assistant message or tool side effect is committed for that attempt.
- **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered turn; direct `ctx.llm.stream()` callers remain single-attempt.
- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`.
- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text.
diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md
index b75e24f2e9..962cea4bf6 100644
--- a/docs/subsystems/llm-streaming.zh.md
+++ b/docs/subsystems/llm-streaming.zh.md
@@ -216,6 +216,16 @@ type StreamChunk =
}
```
+
+
+## 紧凑 Assistant stream
+
+`AssistantStreamAccumulator` 把每个 `StreamChunk` 与其原始安全整数时间戳配对,并生成 `AssistantStreamRecord[]`。同一 block 的连续 text、reasoning 或 tool argument delta 会变成一个 record,使用 `time0`、精确时间戳间隔和每个原始 delta 对应的一个数组成员;其他 chunk 保留为带时间戳的 raw record。该表示会移除重复 event envelope,但不会合并 token 边界,也不会丢弃 terminal、usage、block、failure 或 replay 事实。
+
+`snapshot()` 返回分离且不可变的 stream。`expandAssistantStream()` 会严格检查 record key、成员数、index、时间戳、tool-call identity 与无损 JSON,再重建精确的带时间 chunk 序列。Session 日志会把该 stream 嵌入作为 surface result 的 `assistant/message`,或嵌入没有 surface message 的 `assistant/attempt`。
+
+进程本地 `agent/assistant-stream` frame 承载实时呈现。持久回放、遥测、token 记账与历史 UI 组装会展开嵌入式 settlement,而不会把 live frame 当作持久事实。
+
## `LlmFailure`
@@ -282,7 +292,7 @@ interface LlmImageRequestPricing {
- **`usage` 在 `finish` 之前,`finish` 之后不再有任何分片。** 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。
- **工具调用的 `arguments` 全程保持原始 JSON 字符串。** 部分片段通过 `argumentsDelta` 流式传输;如果提供方返回的是已解析的对象,适配器在 `block-end` 时重新序列化为字符串。
-- **两条受支持的错误路径,共用一个 `LlmFailure` 类型。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。调用选定适配器后,流会保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。
+- **两条受支持的错误路径,共用一个 `LlmFailure` 类型。** 失败可以从 `stream()` 抛出(传输/协议错误),**或者**以 `finish {kind:'error'|'aborted', failure}` 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。`LlmError.failure` 携带同一个 `LlmFailure`。调用选定适配器后,流会保留被抛出的确切 `Error` 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)先把 attempt stream 提交为 `assistant/attempt`,再关闭失败步骤,并把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 `agent/request-error`。处理该错误的 listener 在其 await 的修复完成后返回 `{ kind: 'retry' }`;若未恢复,结构化失败会成为轮次错误,并且该次 attempt 不会提交 surface Assistant message 或工具副作用。
- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的轮次;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。
- **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。
- **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。
diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml
index 6e75f6aa2d..b8ae4ceedf 100644
--- a/docs/subsystems/persistence.i18n.yaml
+++ b/docs/subsystems/persistence.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
-persistence.md: d00e3c5fcd6f2e770abe115bb5c83c06c3e903b6
-persistence.zh.md: 55aa5449d1b8c1635a3f26da3c9e52ad98cc7f65
+persistence.md: 0be36918e78c5f50355c67b345fd5bacd11ac564
+persistence.zh.md: 0d64afa899bb6fe97eba557af056e35077248390
diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md
index d00e3c5fcd..0be36918e7 100644
--- a/docs/subsystems/persistence.md
+++ b/docs/subsystems/persistence.md
@@ -107,7 +107,8 @@ interface CreateSessionOptions {
readonly seed?: readonly SessionEvent[]
/**
* Exact fork-inherited prefix length when `meta.isSeeded` is true. A
- * constructor seed may also contain child-owned setup events after this cut.
+ * In v2 the constructor seed is exactly this inherited prefix; the constructor
+ * appends the child-owned tagged marker at the cut.
*/
readonly inheritedEventCount?: SessionLogOffset
/**
@@ -144,7 +145,7 @@ interface SessionStorageMetadata {
## `SessionRawArtifact` — verbatim stored artifact text
-A backend's selected generation text for one Session, byte-identical to what it durably wrote after decoding the physical compression. `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives. JSONL sets `filename` to the selected logical basename without `.zstd`: `session.jsonl` for v0 and `session.vN.jsonl` for every positive generation. Consumers first test `supportsRawArtifacts`: `false` means the backend does not provide this capability, while `readRaw(...) === undefined` means a supported backend has no materialized generation for that Session.
+A backend's selected generation text for one Session, byte-identical to what it durably wrote after decoding the physical compression. `readRaw` returns it without reconstructing from parsed events, so key order, line breaks, and historical v0/v1 packed rows survive. Current JSONL v2 stores one row per durable event. JSONL sets `filename` to the selected logical basename without `.zstd`: `session.jsonl` for v0 and `session.vN.jsonl` for every positive generation. Consumers first test `supportsRawArtifacts`: `false` means the backend does not provide this capability, while `readRaw(...) === undefined` means a supported backend has no materialized generation for that Session.
```ts type-equiv
/** A backend's own raw artifact text for one session, verbatim. */
diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md
index 55aa5449d1..0d64afa899 100644
--- a/docs/subsystems/persistence.zh.md
+++ b/docs/subsystems/persistence.zh.md
@@ -107,7 +107,8 @@ interface CreateSessionOptions {
readonly seed?: readonly SessionEvent[]
/**
* Exact fork-inherited prefix length when `meta.isSeeded` is true. A
- * constructor seed may also contain child-owned setup events after this cut.
+ * In v2 the constructor seed is exactly this inherited prefix; the constructor
+ * appends the child-owned tagged marker at the cut.
*/
readonly inheritedEventCount?: SessionLogOffset
/**
@@ -144,7 +145,7 @@ interface SessionStorageMetadata {
## `SessionRawArtifact`——逐字存储工件文本
-后端为一个 Session 选定的 generation 文本,在解码物理压缩后与其持久写入内容逐字节相同。`readRaw` 不通过已解析事件重建就返回该文本,因此后端特定的序列化(chunk 打包、key 顺序、换行)都会保留。JSONL 把 `filename` 设为不带 `.zstd` 的选定逻辑 basename:v0 为 `session.jsonl`,每个正 generation 为 `session.vN.jsonl`。消费方先检查 `supportsRawArtifacts`:`false` 表示后端不提供该能力,而 `readRaw(...) === undefined` 表示支持该能力的后端中不存在该 Session 的已物化 generation。
+后端为一个 Session 选定的 generation 文本,在解码物理压缩后与其持久写入内容逐字节相同。`readRaw` 不通过已解析事件重建就返回该文本,因此 key 顺序、换行与历史 v0/v1 packed row 都会保留。当前 JSONL v2 为每个持久事件存储一行。JSONL 把 `filename` 设为不带 `.zstd` 的选定逻辑 basename:v0 为 `session.jsonl`,每个正 generation 为 `session.vN.jsonl`。消费方先检查 `supportsRawArtifacts`:`false` 表示后端不提供该能力,而 `readRaw(...) === undefined` 表示支持该能力的后端中不存在该 Session 的已物化 generation。
```ts type-equiv
/** A backend's own raw artifact text for one session, verbatim. */
diff --git a/docs/subsystems/session-telemetry.i18n.yaml b/docs/subsystems/session-telemetry.i18n.yaml
index b21cceb024..8cbe45f67f 100644
--- a/docs/subsystems/session-telemetry.i18n.yaml
+++ b/docs/subsystems/session-telemetry.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-telemetry.md
-session-telemetry.md: 5f589d00940039cdfd6b7d50f8689b4280b94c83
-session-telemetry.zh.md: bb08eb348874c0c3bae4cacdec16ac954193726e
+session-telemetry.md: 83224cf03005909f9b3d600a950c2d6402ca3b2a
+session-telemetry.zh.md: a093590819d62839fb69a0e50b2d11f495171ec8
diff --git a/docs/subsystems/session-telemetry.md b/docs/subsystems/session-telemetry.md
index 5f589d0094..83224cf030 100644
--- a/docs/subsystems/session-telemetry.md
+++ b/docs/subsystems/session-telemetry.md
@@ -55,7 +55,7 @@ interface SessionTelemetryRecord {
}
```
-Every canonical [session event](session.md), including every `assistant/chunk` and plugin-merged type the seam never heard of, passes through whole as one ordered ledger record. A new Session object replays its complete log from seq 0, including constructor seed history; re-adopting the same object resumes after its handoff cursor. Delivery is best-effort: the cursor marks handed-off, not delivered, and records can be lost (crash, reload window) or duplicated (new-object replay, SDK retries), so receivers dedupe ledger records on `(session.id, session.format_version, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead.
+Every canonical [session event](session.md), including each `assistant/message` or `assistant/attempt` with its complete compact stream and every plugin-merged type the seam never heard of, passes through whole as one ordered ledger record. Process-local `agent/assistant-stream` frames do not enter this durable feed. A new Session object replays its complete log from seq 0, including constructor seed history; re-adopting the same object resumes after its handoff cursor. Delivery is best-effort: the cursor marks handed-off, not delivered, and records can be lost (crash, reload window) or duplicated (new-object replay, SDK retries), so receivers dedupe ledger records on `(session.id, session.format_version, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead.
## The sharing disclosure
diff --git a/docs/subsystems/session-telemetry.zh.md b/docs/subsystems/session-telemetry.zh.md
index bb08eb3488..a093590819 100644
--- a/docs/subsystems/session-telemetry.zh.md
+++ b/docs/subsystems/session-telemetry.zh.md
@@ -55,7 +55,7 @@ interface SessionTelemetryRecord {
}
```
-每条权威[会话事件](session.zh.md)都会完整透传为一条有序 ledger 记录,包括每条 `assistant/chunk` 以及该 seam 从未听说过、由插件合并进来的类型。新 Session 对象会从 seq 0 回放完整日志,包括构造 seed 历史;重新收养同一对象时会从 handoff 游标之后继续。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(新对象回放、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, session.format_version, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。
+每条权威[会话事件](session.zh.md)都会完整透传为一条有序 ledger 记录,包括每个携带完整紧凑 stream 的 `assistant/message` 或 `assistant/attempt`,以及该 seam 从未听说过、由插件合并进来的类型。进程本地 `agent/assistant-stream` frame 不进入该持久 feed。新 Session 对象会从 seq 0 回放完整日志,包括构造 seed 历史;重新收养同一对象时会从 handoff 游标之后继续。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(新对象回放、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, session.format_version, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。
## 共享披露
diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml
index a18329547e..2a44ec994d 100644
--- a/docs/subsystems/session.i18n.yaml
+++ b/docs/subsystems/session.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 176205799690f9099d9e9231cd5a2d23bc639684
-session.zh.md: 657d500d131f56469078631fc17b6db9ba404125
+session.md: 7a3a876840f4af26447df8e37b6b5ed04a6cdd85
+session.zh.md: 61729b42e874e805d1ce847d568b0dc38ec23d30
diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md
index 1762057996..7a3a876840 100644
--- a/docs/subsystems/session.md
+++ b/docs/subsystems/session.md
@@ -21,8 +21,8 @@ interface UserMessage extends Message {
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
- * sequence numbers stay contiguous, including raw chunks, so persistence can
- * store the canonical log verbatim.
+ * sequence numbers stay contiguous. Assistant attempt events embed their exact
+ * compact raw streams so persistence stores one durable settlement per attempt.
*/
interface SessionEventMap {
/**
@@ -53,8 +53,6 @@ interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
- /** Raw stream chunk — token-level replay fidelity. */
- 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
* Assembled assistant message for one step (derived history uses this).
* Carries the step's `usage` when the adapter reported token accounting, so
@@ -65,7 +63,21 @@ interface SessionEventMap {
* marker distinguishes that prefix without re-deriving interruption from turn
* boundaries. An aborted turn with no such event streamed no visible content.
*/
- 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
+ 'assistant/message': {
+ turn: number
+ step: number
+ message: AssistantMessage
+ /** Exact timed model stream, compacted without joining delta boundaries. */
+ stream: AssistantStreamRecord[]
+ usage?: TokenUsage
+ interrupted?: true
+ }
+ /**
+ * One model attempt that committed no surface message. The embedded stream
+ * preserves failed, retried, cancelled, or crash-tail output without
+ * fabricating model-visible history.
+ */
+ 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
@@ -109,12 +121,12 @@ interface SessionEventMap {
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
- * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
- * carry the meaning.
+ * {@link Session.firstLiveSeq}.
*
- * Locate the LAST one in stored history. A seed already ending in one is not
- * re-marked, so reopening an untouched session does not grow its log per
- * pickup and the event need not be at the current `firstLiveSeq`.
+ * A fresh fork child owns one `{ inherited: true }` marker at its exact
+ * inherited-prefix cut, even when that prefix ends in an ancestor marker.
+ * The last tagged marker is the current Session's cut; untagged markers keep
+ * ordinary restore and replay lifecycle boundaries.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
@@ -127,7 +139,7 @@ interface SessionEventMap {
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
- 'session/end-seed': Record
+ 'session/end-seed': { inherited?: true }
}
```
@@ -211,7 +223,7 @@ type OptionalSessionSeq = SessionSeq | null
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`).
- * Non-surface events (boundary markers, chunks, usage, errors) never carry
+ * Non-surface events (boundary markers, attempts, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
@@ -236,12 +248,9 @@ type SessionEvent = {
ignorable?: true
} & (K extends SurfaceEventType ? {
/**
- * Seq numbers of earlier events that this event cites as sources
- * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
- * or the surface nodes shadowed by a compaction replace node). An
- * `assistant/message` may carry a present empty array for a known empty
- * provider stream; when the field is absent, the event does not record which
- * earlier events produced the message.
+ * Seq numbers of earlier events that this event cites as sources, such as
+ * the surface nodes shadowed by a compaction replacement. A v2
+ * `assistant/message` embeds its provider stream and cannot carry this field.
*/
sourceEventSeqs?: SessionSeq[]
/** How this event entered the surface; absent for non-surface events. */
@@ -252,7 +261,7 @@ type SessionEvent = {
`SessionEventType = keyof SessionEventMap`. Because `SessionEventMap` is merge-extensible, switches over `SessionEvent` must NOT use `assertNever` — a plugin-added variant is a valid unknown value; handle the known cases and fall through `default`.
-For `assistant/message`, a present `sourceEventSeqs: []` is a complete known-empty provider stream, while a legacy or foreign event with no field does not record which earlier events produced the message. The loop writes the field for every successful model call; every other surface event requires a non-empty list when the field is present.
+V2 `assistant/message` embeds its provider stream and cannot carry `sourceEventSeqs`. User and tool surface events may cite a complete non-empty set of unique earlier events when their provenance or replacement operation requires it.
## Surface types
@@ -264,7 +273,8 @@ The three message-producing types (`SurfaceEventType` — `user/message`, `assis
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these
- * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
+ * event types may carry {@link SurfaceOp}; user and tool events may also cite
+ * earlier sources through {@link SessionEvent.sourceEventSeqs}.
*/
type SurfaceEventType =
| 'user/message'
@@ -302,21 +312,20 @@ type SurfaceOp =
* Surface placement and cited source-event seqs for {@link Session.append}. Required on
* message-producing events and forbidden on log-only events.
*/
-interface SurfaceIntent {
+type SurfaceIntent = {
surfaceOp: SurfaceOp
- /**
- * Complete set of known source-event seqs. `assistant/message` may use a
- * present empty array for a known empty provider stream; when the field is
- * absent, the event does not record which earlier events produced the message.
- * Other surface events require a non-empty set when this field is present.
- */
+} & (T extends 'assistant/message' ? {
+ /** V2 Assistant messages embed their provider stream instead of citing source events. */
+ sourceEventSeqs?: never
+} : {
+ /** Complete non-empty set of known earlier source-event seqs. */
sourceEventSeqs?: SessionSeq[]
-}
+})
```
Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. A human-facing transcript is the other projection and reads the log's append-origin events instead, because the surface deliberately shadows the ranges a replacement summarizes (`isAppendSurfaceEvent` in [dsh-session](../../packages/core/session/README.md)). Non-surface types reject it at compile time.
-Only `assistant/message` may carry a present empty `sourceEventSeqs`; when the field is absent, the event does not record which earlier events produced the message, and the provider may still have emitted chunks.
+`assistant/message` cannot carry `sourceEventSeqs`; its `stream` owns exact provider evidence. Other surface events omit the field when they cite no earlier event and use a complete non-empty list when they do.
### `SessionSurface` — the live readonly surface projection
@@ -494,7 +503,8 @@ declare class Session {
* declare how it joins the surface, the sole source of derived model
* history) and
* rejected by the compiler for non-surface types like `turn/start` or
- * `assistant/chunk`.
+ * `assistant/attempt`. Assistant messages embed their exact provider
+ * stream and cannot cite top-level source events.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input.
@@ -515,7 +525,7 @@ declare class Session {
append(
type: T,
data: SessionEventMap[T],
- ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
+ ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent;
/**
* The {@link EpochHeader} in force after the log's last header event — the
@@ -566,11 +576,11 @@ declare class Session {
`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules:
- `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata.
-- `assistant/message` → an assistant message with the provider and model that produced it plus optional adapter-private replay state. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to hold its usage, provider, and model, but a content-less assistant turn must not enter the provider transcript.
+- `assistant/message` → an assistant message with the provider and model that produced it plus optional adapter-private replay state. Its embedded compact stream is replay, usage, and UI evidence rather than a second message. An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to hold its stream, usage, provider, and model, but a content-less assistant turn must not enter the provider transcript.
- `tool/result` → a user message carrying a `tool-result` block.
- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; its typed source names the producer and carries any producer-specific data.
-Everything else (`turn/*`, `step/*`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message, so their usage chunk is the durable accounting record. Current logical validation rejects request headers and assistant messages that omit provider/model instead of guessing a route; supported historical representations are normalized and validated by their adjacent format edge before a current Session exists.
+Everything else (`turn/*`, `step/*`, `assistant/attempt`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting expands the embedded stream on each `assistant/message` or `assistant/attempt`, while the message's top-level `usage` remains the committed-message authority when present. A failed model-request attempt therefore retains its provider usage without fabricating an assistant message. Current logical validation rejects request headers and assistant messages that omit provider/model instead of guessing a route; supported historical representations are normalized and validated by their adjacent format edge before a current Session exists.
## Live-session fork API
@@ -625,9 +635,9 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c
## The end-seed boundary: `session/end-seed`
-A Session constructed with an explicit seed — restore, fork, or replay — appends this log-only event immediately after that constructor seed, as its first live write. Events before it have smaller seq values and came through construction. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. It does not define fork ownership; `isSeeded` plus `inheritedEventCount` do. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer.
+A fresh fork constructor requires its seed to equal the inherited prefix and appends `session/end-seed { inherited: true }` at the exact durable cut. A restore retains that tagged marker and appends an ordinary `session/end-seed {}` only when its complete stored seed does not already end in a marker. Both forms are log-only and produce no message; `Session`'s constructor is the only legitimate writer.
-An explicitly supplied empty seed writes `session/end-seed` at seq 0, which distinguishes an empty resumed session from a fresh one. A seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`.
+For fork lineage, locate the LAST marker whose payload carries `inherited: true`; v2 decoding requires it exactly when `SessionHeader.isSeeded` is true and derives `inheritedEventCount` from its seq. For lifecycle ownership, locate the last `session/end-seed` of either form. Reopening a seed that already ends in any marker does not append another ordinary marker.
It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compaction/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compaction/*`.
@@ -643,7 +653,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h
## 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 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.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
+What a persistence backend relies on: the durable log persists every event losslessly, and every Assistant attempt is one `assistant/message` or `assistant/attempt` whose embedded compact stream preserves the original timed chunks. `seq` stays contiguous across these settlements and all interleaved events. A backend may choose its own storage framing for an event batch as long as `load` returns the exact appended events; current JSONL v2 writes one row per event (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.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format.
The backends that consume this contract are on [persistence.md](persistence.md).
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index 657d500d13..61729b42e8 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -21,8 +21,8 @@ interface UserMessage extends Message {
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
- * sequence numbers stay contiguous, including raw chunks, so persistence can
- * store the canonical log verbatim.
+ * sequence numbers stay contiguous. Assistant attempt events embed their exact
+ * compact raw streams so persistence stores one durable settlement per attempt.
*/
interface SessionEventMap {
/**
@@ -53,8 +53,6 @@ interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
- /** Raw stream chunk — token-level replay fidelity. */
- 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
* Assembled assistant message for one step (derived history uses this).
* Carries the step's `usage` when the adapter reported token accounting, so
@@ -65,7 +63,21 @@ interface SessionEventMap {
* marker distinguishes that prefix without re-deriving interruption from turn
* boundaries. An aborted turn with no such event streamed no visible content.
*/
- 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
+ 'assistant/message': {
+ turn: number
+ step: number
+ message: AssistantMessage
+ /** Exact timed model stream, compacted without joining delta boundaries. */
+ stream: AssistantStreamRecord[]
+ usage?: TokenUsage
+ interrupted?: true
+ }
+ /**
+ * One model attempt that committed no surface message. The embedded stream
+ * preserves failed, retried, cancelled, or crash-tail output without
+ * fabricating model-visible history.
+ */
+ 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
@@ -109,12 +121,12 @@ interface SessionEventMap {
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
- * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
- * carry the meaning.
+ * {@link Session.firstLiveSeq}.
*
- * Locate the LAST one in stored history. A seed already ending in one is not
- * re-marked, so reopening an untouched session does not grow its log per
- * pickup and the event need not be at the current `firstLiveSeq`.
+ * A fresh fork child owns one `{ inherited: true }` marker at its exact
+ * inherited-prefix cut, even when that prefix ends in an ancestor marker.
+ * The last tagged marker is the current Session's cut; untagged markers keep
+ * ordinary restore and replay lifecycle boundaries.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
@@ -127,7 +139,7 @@ interface SessionEventMap {
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
- 'session/end-seed': Record
+ 'session/end-seed': { inherited?: true }
}
```
@@ -211,7 +223,7 @@ type OptionalSessionSeq = SessionSeq | null
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`).
- * Non-surface events (boundary markers, chunks, usage, errors) never carry
+ * Non-surface events (boundary markers, attempts, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
@@ -236,12 +248,9 @@ type SessionEvent = {
ignorable?: true
} & (K extends SurfaceEventType ? {
/**
- * Seq numbers of earlier events that this event cites as sources
- * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
- * or the surface nodes shadowed by a compaction replace node). An
- * `assistant/message` may carry a present empty array for a known empty
- * provider stream; when the field is absent, the event does not record which
- * earlier events produced the message.
+ * Seq numbers of earlier events that this event cites as sources, such as
+ * the surface nodes shadowed by a compaction replacement. A v2
+ * `assistant/message` embeds its provider stream and cannot carry this field.
*/
sourceEventSeqs?: SessionSeq[]
/** How this event entered the surface; absent for non-surface events. */
@@ -252,7 +261,7 @@ type SessionEvent = {
`SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 语句禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。
-对于 `assistant/message`,存在的 `sourceEventSeqs: []` 表示提供方流已知且完整地为空;旧格式或外部事件缺少该字段时,没有记录这条消息由哪些早期事件产生。agent loop 会为每次成功的模型调用写入该字段;其他 surface 事件只要包含该字段,其列表就必须非空。
+V2 `assistant/message` 嵌入 provider stream,不能携带 `sourceEventSeqs`。User 与 tool surface event 可以在 provenance 或 replacement operation 需要时引用完整且非空的唯一较早 event 集合。
@@ -266,7 +275,8 @@ type SessionEvent = {
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these
- * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
+ * event types may carry {@link SurfaceOp}; user and tool events may also cite
+ * earlier sources through {@link SessionEvent.sourceEventSeqs}.
*/
type SurfaceEventType =
| 'user/message'
@@ -304,21 +314,20 @@ type SurfaceOp =
* Surface placement and cited source-event seqs for {@link Session.append}. Required on
* message-producing events and forbidden on log-only events.
*/
-interface SurfaceIntent {
+type SurfaceIntent = {
surfaceOp: SurfaceOp
- /**
- * Complete set of known source-event seqs. `assistant/message` may use a
- * present empty array for a known empty provider stream; when the field is
- * absent, the event does not record which earlier events produced the message.
- * Other surface events require a non-empty set when this field is present.
- */
+} & (T extends 'assistant/message' ? {
+ /** V2 Assistant messages embed their provider stream instead of citing source events. */
+ sourceEventSeqs?: never
+} : {
+ /** Complete non-empty set of known earlier source-event seqs. */
sourceEventSeqs?: SessionSeq[]
-}
+})
```
对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的 transcript(文本记录)是另一个投影,读取的是日志中追加来源的事件,因为 surface 会有意遮蔽替换所概括的范围(见 [dsh-session](../../packages/core/session/README.zh.md) 的 `isAppendSurfaceEvent`)。非 surface 类型在编译期拒绝此参数。
-只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;字段不存在时,该事件没有记录这条消息由哪些早期事件产生,但提供方仍可能发出过分片。
+`assistant/message` 不能携带 `sourceEventSeqs`;它的 `stream` 拥有精确 provider 证据。其他 surface event 不引用较早 event 时省略该字段,需要引用时使用完整非空 list。
### `SessionSurface`:实时只读 surface 投影
@@ -496,7 +505,8 @@ declare class Session {
* declare how it joins the surface, the sole source of derived model
* history) and
* rejected by the compiler for non-surface types like `turn/start` or
- * `assistant/chunk`.
+ * `assistant/attempt`. Assistant messages embed their exact provider
+ * stream and cannot cite top-level source events.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input.
@@ -517,7 +527,7 @@ declare class Session {
append(
type: T,
data: SessionEventMap[T],
- ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
+ ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent;
/**
* The {@link EpochHeader} in force after the log's last header event — the
@@ -568,11 +578,11 @@ declare class Session {
`Session.deriveMessages()` 将事件日志投影为模型看到的 `Message[]`。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。`deriveEventMessage(event)` 是折叠所应用的逐节点纯函数,公开暴露以便外部重建器和开发不变式检查能以完全相同的规则投影日志前缀,不会与缓存产生分歧。投影规则:
- `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。
-- `assistant/message` → 一条 assistant 消息,包含生成它的提供方和模型,以及可选的适配器私有回放状态。原始 `assistant/chunk` 事件属于回放/UI 数据,在派生时会被**跳过**(组装后的消息才是权威)。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 来保存用量、提供方和模型,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。
+- `assistant/message` → 一条 assistant 消息,包含生成它的提供方和模型,以及可选的适配器私有回放状态。其嵌入式紧凑 stream 是回放、usage 与 UI 证据,而不是第二条 message。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 来保存 stream、usage、提供方和模型,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。
- `tool/result` → 一条携带 `tool-result` 块的 user 消息。
- `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;其类型化 source 标明生产方,并携带所有生产方专用数据。
-其余所有事件(`turn/*`、`step/*`、插件所属的 `llm/retry`)均为结构信息,不会投影为消息。token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息,因此其用量分片是持久化的记账记录。当前逻辑校验会拒绝没有提供方/模型的 request header 和 assistant 消息,而不会猜测路由;受支持的历史表示会在当前 Session 存在前,由其相邻格式迁移边归一化并校验。
+其余所有事件(`turn/*`、`step/*`、`assistant/attempt`、插件所属的 `llm/retry`)均为结构信息,不会投影为消息。token 记账会展开每个 `assistant/message` 或 `assistant/attempt` 的嵌入式 stream,message 顶层 `usage` 存在时仍是已提交 message 的权威。失败的模型请求 attempt 因此可以保留提供方 usage,而无需虚构 assistant message。当前逻辑校验会拒绝没有提供方/模型的 request header 和 assistant 消息,而不会猜测路由;受支持的历史表示会在当前 Session 存在前,由其相邻格式迁移边归一化并校验。
## 活跃会话 fork API
@@ -629,9 +639,9 @@ interface TurnEndReasonMap {
## 种子结束边界:`session/end-seed`
-用显式 seed 构造的 Session(restore、fork 或 replay)会紧接该 constructor seed 之后追加这个仅日志事件,作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且经由构造进入。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的 consumer 回答本 lifecycle 的写入从哪里开始,该事件则为只持有存储字节的 consumer 回答同一问题。它不定义 fork ownership;`isSeeded` 与 `inheritedEventCount` 才定义。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
+新 fork constructor 要求 seed 等于 inherited prefix,并在精确持久 cut 追加 `session/end-seed { inherited: true }`。restore 会保留该 tagged marker,并且只在完整 stored seed 尚未以 marker 结尾时追加普通 `session/end-seed {}`。两种形式都只进入 log 且不产生 message;`Session` constructor 是唯一合法 writer。
-显式传入的空种子会在 seq 0 写入 `session/end-seed`,从而把从空日志恢复的会话与全新会话区分开来。种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。
+对于 fork lineage,定位 payload 携带 `inherited: true` 的最后一个 marker;v2 decoding 只在 `SessionHeader.isSeeded` 为 true 时要求该 marker,并从其 seq 推导 `inheritedEventCount`。对于 lifecycle ownership,定位任一形式的最后一个 `session/end-seed`。重新打开已经以任一 marker 结尾的 seed 时,不会再追加普通 marker。
它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compaction/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compaction/*` 的原因。
@@ -647,7 +657,7 @@ interface TurnEndReasonMap {
## 持久性约定
-持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
+持久化后端依赖的约定如下:持久日志无损保存每个事件,每个 Assistant attempt 都是一个 `assistant/message` 或 `assistant/attempt`,其嵌入式紧凑 stream 会保留原始带时间 chunk。`seq` 在这些 settlement 与所有交错事件之间保持连续。后端可以为事件批次选择自己的存储 framing,只要 `load` 返回与追加时完全一致的事件即可;当前 JSONL v2 每个事件写一行(见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。
消费此约定的后端见 [persistence.md](persistence.zh.md)。
diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml
index 5a83be6c89..213853f56d 100644
--- a/docs/subsystems/web-client.i18n.yaml
+++ b/docs/subsystems/web-client.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/web-client.md
-web-client.md: 166ad50df661e37318c5ed2f271569c292cce39a
-web-client.zh.md: cdf91958e23c0ea5c99562e6ca347947fbeff292
+web-client.md: 7e7f483131585dc66c45dbb147d40e848f40d6d1
+web-client.zh.md: 603af505dbd1fc6e2f9fd3bf9542ce19cb63732f
diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md
index 166ad50df6..7e7f483131 100644
--- a/docs/subsystems/web-client.md
+++ b/docs/subsystems/web-client.md
@@ -55,7 +55,7 @@ This pairing is not a second source of business truth. Host controllers decide d
`ui-session` installs the `session` scope adapter and publishes `useSessions`, `useSession`, `sessionId`, and `useProjection`. Domain adapters add further standard sources without putting React hooks on the model objects.
-`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates standard events and Client-only `chunkrow/*` history events into stable business Contexts, and its view registry materializes target snapshots. Packed runs stay single inputs and Matches through replay; Chat Assistant, Trajectory Assistant, and Turn Tail are the built-in Definitions that interpret them. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
+`ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates durable Session events and Client-only `assistant/live-chunk` updates into stable business Contexts, and its view registry materializes target snapshots. Chat Assistant, Trajectory Assistant, and Turn Tail interpret both live chunks and the compact streams embedded in durable settlements, so reconnect and paged history reproduce the same Assistant state without durable token rows. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
`ui-slots` provides the typed registry and lifecycle ledger; `ui-renderer` is the only package that binds bare observables through `useSyncExternalStore`, owns React contexts, and renders the root tree. Feature components receive framework hooks, owner props, store actions, and explicit injection through their derived props. [Web Client Slots](slots.md) lists those inputs, extension APIs, and the current Slot hierarchy.
diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md
index cdf91958e2..603af505db 100644
--- a/docs/subsystems/web-client.zh.md
+++ b/docs/subsystems/web-client.zh.md
@@ -55,7 +55,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
`ui-session` 安装 `session` scope adapter,并提供 `useSessions`、`useSession`、`sessionId` 和 `useProjection`。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
-`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把标准 event 与 Client-only `chunkrow/*` 历史 event 关联成稳定的业务 Context,view registry 则 materialize target snapshot。packed run 在 replay 全程保持为单个 input 与 Match;Chat Assistant、Trajectory Assistant 和 Turn Tail 是解释它的三个内建 Definition。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
+`ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把持久 Session event 与 Client-only `assistant/live-chunk` update 关联成稳定的业务 Context,view registry 则 materialize target snapshot。Chat Assistant、Trajectory Assistant 与 Turn Tail 同时解释 live chunk 和持久 settlement 中嵌入的紧凑 stream,因此重连与分页历史无需持久 token 行即可复现相同 Assistant 状态。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
`ui-slots` 提供类型化 registry 与 lifecycle ledger;`ui-renderer` 是唯一通过 `useSyncExternalStore` 绑定裸 observable、拥有 React context 并渲染 root tree 的包。功能 component 通过推导出的 props 接收 framework hook、owner prop、store action 与显式 injection。[Web Client Slots](slots.zh.md)列出这些输入、扩展 API 与当前 Slot 层级。
diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts
index c44ee3ab2e..a17223750f 100644
--- a/packages/acp/acp/tests/turns.spec.ts
+++ b/packages/acp/acp/tests/turns.spec.ts
@@ -203,8 +203,8 @@ describe('ACP prompt lifecycle', () => {
const agent = harness.ctx.agents.get(SessionId(sessionId))!
let autonomousStarted!: () => void
const started = new Promise((resolve) => { autonomousStarted = resolve })
- harness.ctx.on('session/event', (session, event) => {
- if (session === agent.session && event.type === 'assistant/chunk') autonomousStarted()
+ harness.ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => {
+ if (subject === agent && frame.type === 'chunk') autonomousStarted()
})
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'autonomous work' }],
diff --git a/packages/acp/acp/tests/updates.spec.ts b/packages/acp/acp/tests/updates.spec.ts
index fe070086c1..4363db4f86 100644
--- a/packages/acp/acp/tests/updates.spec.ts
+++ b/packages/acp/acp/tests/updates.spec.ts
@@ -14,6 +14,7 @@ function assistantEvent(
seq: SessionSeq(0),
time: 0,
data: {
+ stream: [],
turn: 1,
step: 1,
message: {
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index c1a4319677..4162cb813c 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 601e1037792ff0965ce862324d1e85a714cb3c45
-README.zh.md: a0bc858ce92783b6e3e1c95a3664faa85f10a03a
+README.md: 6d79ab00cd997065a140fcc95213334987e1db13
+README.zh.md: 30ca5ff7ccbb93d2fe46943c65b5474d01663dd5
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 601e103779..6d79ab00cd 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -23,11 +23,11 @@ English | [中文](README.zh.md)
## Use this package
-History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
+History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
-The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless assistant notifications: each opening carries active attempts with their `startedTime`, current chunks, and exact v1 seq provenance. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. If the Assistant baseline already contains a chunk whose durable event was captured after the opening page, the Client publishes that event on arrival from the exact baseline seq proof. A final durable message arriving after an active opening remains staged until its committed end frame carries the same ordered source seqs and `end.index` equals the next chunk position. A revision or dense-index gap reopens follow. A durable gap-repair page carries no Assistant baseline; the Client clears transient attempts, and a held notification reopens follow for a paired page and baseline. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedTime`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. If an opening lands between a durable `assistant/message` or `assistant/attempt` and its end frame, the Session object keeps that settlement staged while leaving earlier same-step retries visible; the matching end type, seq, and index publish it. Revision, dense-index, or settlement gaps reopen follow, and an abandoned end publishes no durable settlement. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index a0bc858ce9..30ca5ff7cc 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -23,11 +23,11 @@ kind: "package-reference"
## 使用本包
-历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
+历史页与 follow opening snapshot 为每个持久 Session event 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑 stream 内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
-Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 assistant 通知:每个 opening 携带活跃尝试的 `startedTime`、当前 chunk 和精确 v1 seq 来源。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。如果 Assistant baseline 已包含一个在 opening page 之后捕获持久事件的 chunk,Client 会依据 baseline 中精确的 seq 证明,在该事件到达时直接发布。活跃 opening 之后到达的最终持久 message 会保持暂存,直到 committed end 帧携带相同的有序 source seq,且 `end.index` 等于下一个 chunk 位置。revision 或连续 index 缺口会重新打开 follow。持久缺口修复 page 不携带 Assistant baseline;Client 会清空瞬态尝试,并由 held notification 重新打开 follow,以取得配对的 page 与 baseline。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
+Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedTime`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。如果 opening 位于持久 `assistant/message` 或 `assistant/attempt` 与对应 end frame 之间,Session 对象会暂存该 settlement,同时让同一步骤中更早的 retry 保持可见;匹配的 end type、seq 与 index 到达后再发布。revision、密集 index 或 settlement 缺口会重新打开 follow,abandoned end 不发布持久 settlement。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
diff --git a/packages/api/session-controller/src/assistant-stream.ts b/packages/api/session-controller/src/assistant-stream.ts
index 0168cb4a4b..5cac9ccd90 100644
--- a/packages/api/session-controller/src/assistant-stream.ts
+++ b/packages/api/session-controller/src/assistant-stream.ts
@@ -1,31 +1,30 @@
/** Process-local assistant state retained for reconnecting Web followers. */
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
+import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
SessionAssistantStreamAttempt,
SessionAssistantStreamBaseline,
} from './types.ts'
-type ChunkFrame = Extract
-
interface MutableAttempt {
readonly attemptId: SessionAssistantStreamAttempt['attemptId']
readonly startedTime: number
readonly turn: number
readonly step: number
- readonly chunks: ChunkFrame[]
- readonly legacyChunkSeqs: number[]
+ readonly stream: AssistantStreamAccumulator
+ nextIndex: number
}
-const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0, attempts: [] }
+const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0 }
/**
* Folds dense Agent frames and materializes one shared immutable reconnect
* baseline per accepted revision.
*/
export class SessionAssistantStreamAccumulator {
- private readonly attempts = new Map()
+ private activeAttempt: MutableAttempt | undefined
private revision = 0
private snapshotValue: SessionAssistantStreamBaseline = EMPTY_BASELINE
private dirty = false
@@ -36,11 +35,11 @@ export class SessionAssistantStreamAccumulator {
*/
accept(frame: AssistantStreamFrame): void {
if (frame.type === 'start' && frame.revision === 1 && this.revision !== 0) {
- this.attempts.clear()
+ this.activeAttempt = undefined
this.revision = 0
}
if (frame.revision !== this.revision + 1) {
- this.attempts.clear()
+ this.activeAttempt = undefined
this.revision = frame.revision
this.dirty = true
return
@@ -48,27 +47,29 @@ export class SessionAssistantStreamAccumulator {
this.revision = frame.revision
switch (frame.type) {
case 'start':
- this.attempts.set(String(frame.attemptId), {
+ this.activeAttempt = {
attemptId: frame.attemptId,
startedTime: frame.startedTime,
turn: frame.turn,
step: frame.step,
- chunks: [],
- legacyChunkSeqs: [],
- })
+ stream: new AssistantStreamAccumulator(),
+ nextIndex: 0,
+ }
break
case 'chunk': {
- const attempt = this.attempts.get(String(frame.attemptId))
- if (attempt === undefined || frame.index !== attempt.chunks.length) {
- this.attempts.clear()
+ const attempt = this.activeAttempt
+ if (attempt === undefined
+ || attempt.attemptId !== frame.attemptId
+ || frame.index !== attempt.nextIndex) {
+ this.activeAttempt = undefined
break
}
- attempt.chunks.push(frame)
- attempt.legacyChunkSeqs.push(frame.legacyChunkSeq)
+ attempt.stream.push({ time: frame.time, chunk: frame.chunk })
+ attempt.nextIndex += 1
break
}
case 'end':
- this.attempts.delete(String(frame.attemptId))
+ this.activeAttempt = undefined
break
}
this.dirty = true
@@ -82,14 +83,16 @@ export class SessionAssistantStreamAccumulator {
if (!this.dirty) return this.snapshotValue
this.snapshotValue = {
revision: this.revision,
- attempts: [...this.attempts.values()].map(attempt => ({
- attemptId: attempt.attemptId,
- startedTime: attempt.startedTime,
- turn: attempt.turn,
- step: attempt.step,
- chunks: attempt.chunks.map(frame => frame.chunk as JsonValue),
- legacyChunkSeqs: [...attempt.legacyChunkSeqs],
- })),
+ ...this.activeAttempt === undefined ? {} : {
+ activeAttempt: {
+ attemptId: this.activeAttempt.attemptId,
+ startedTime: this.activeAttempt.startedTime,
+ turn: this.activeAttempt.turn,
+ step: this.activeAttempt.step,
+ nextIndex: this.activeAttempt.nextIndex,
+ stream: this.activeAttempt.stream.snapshot() as unknown as readonly JsonValue[],
+ },
+ },
}
this.dirty = false
return this.snapshotValue
diff --git a/packages/api/session-controller/src/client/contract/events.ts b/packages/api/session-controller/src/client/contract/events.ts
index 39ce09ccab..b9ff334233 100644
--- a/packages/api/session-controller/src/client/contract/events.ts
+++ b/packages/api/session-controller/src/client/contract/events.ts
@@ -1,18 +1,33 @@
/** Observable contiguous Session event window consumed by domain assemblers. */
import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { LlmAttemptId, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { ChunkRowEvent } from '../../types.ts'
-/** Standard Session event or compact historical Assistant run. */
-export type SessionEventLike = SessionEvent | ChunkRowEvent
+/** Client-only live chunk presentation; `seq` orders the transient row between durable Session seqs. */
+export interface AssistantLiveChunkEvent {
+ readonly type: 'assistant/live-chunk'
+ readonly seq: number
+ readonly time: number
+ readonly data: {
+ readonly attemptId: LlmAttemptId
+ readonly turn: number
+ readonly step: number
+ readonly chunk: StreamChunk
+ }
+}
+
+/** Current durable Session event or one client-only live chunk presentation. */
+export type SessionEventLike = SessionEvent | AssistantLiveChunkEvent
/** Client history entry retaining its coarse transport discriminator. */
export type SessionEventLikeEntry =
| { readonly type: 'event'; readonly event: SessionEvent }
- | { readonly type: 'chunks'; readonly event: ChunkRowEvent }
+ | { readonly type: 'transient'; readonly event: AssistantLiveChunkEvent }
/** Scalar live entry accepted by append-only Client paths. */
export type SessionLiveEventEntry = Extract
+/** Client-only Assistant frame admitted outside durable cursor algebra. */
+export type SessionTransientEventEntry = Extract
interface EventWindowLeaf {
readonly kind: 'leaf'
@@ -78,7 +93,7 @@ function windowSnapshot(
export type SessionEventChange =
| { readonly kind: 'replace'; readonly entries: readonly SessionEventLikeEntry[] }
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventLikeEntry[] }
- | { readonly kind: 'append'; readonly entries: readonly SessionLiveEventEntry[] }
+ | { readonly kind: 'append'; readonly entries: readonly SessionEventLikeEntry[] }
/** Current contiguous event window and its latest synchronous delta. */
export interface SessionEventWindow {
@@ -139,7 +154,7 @@ export class MutableSessionEventSource implements SessionEventSource {
* Append one contiguous live entry.
* @param entry - live tail entry.
*/
- append(entry: SessionLiveEventEntry): void {
+ append(entry: SessionEventLikeEntry): void {
const entries = [entry]
this.window = concat(this.window, leaf(entries))
this.publish(this.snapshot.hasMore, {
diff --git a/packages/api/session-controller/src/client/sessions/assistant-stream.ts b/packages/api/session-controller/src/client/sessions/assistant-stream.ts
index 85973799cc..2aec0f8a50 100644
--- a/packages/api/session-controller/src/client/sessions/assistant-stream.ts
+++ b/packages/api/session-controller/src/client/sessions/assistant-stream.ts
@@ -1,154 +1,191 @@
-/** Web presentation fold joining durable v1 events with transient assistant frames. */
+/** Web presentation fold joining transient Assistant frames to one durable v2 settlement. */
import type {
SessionAssistantStreamBaseline,
SessionAssistantStreamFrame,
} from '../../types.ts'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
+import type { AssistantStreamRecord } from '@deepseek-ai/dsh-llm/assistant-stream'
import type {
SessionEventLikeEntry,
SessionLiveEventEntry,
+ SessionTransientEventEntry,
} from '../contract/events.ts'
interface ActiveAttempt {
+ readonly attemptId: string
readonly startedTime: number
readonly turn: number
readonly step: number
- readonly legacyChunkSeqs: Set
nextIndex: number
}
/** One Web publication decision from the assistant stream fold. */
export type ClientAssistantStreamResult =
| { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
+ | { readonly type: 'transient'; readonly entry: SessionTransientEventEntry }
| { readonly type: 'rebaseline' }
| undefined
-function positionKey(turn: number, step: number): string {
- return `${String(turn)}:${String(step)}`
-}
-
-function sameSeqs(left: readonly number[], right: readonly number[]): boolean {
- return left.length === right.length && left.every((seq, index) => seq === right[index])
-}
-
-/**
- * Keeps transient Assistant presentation behind one small interface. Durable
- * chunks and final messages publish only at their matching live frame.
- */
+/** Keeps transient Assistant presentation behind one settlement-aware interface. */
export class ClientAssistantStream {
- private readonly attempts = new Map()
- private readonly pendingChunks = new Map()
- private readonly pendingMessages = new Map()
+ private activeAttempt: ActiveAttempt | undefined
+ private readonly pending = new Map()
private publishedSeqs = new Set()
+ private durableCursor = -1
+ private transientInGap = 0
/**
* Replace the durable Web window and adopt an optional reconnect baseline.
- * @param entries - complete event window from the journal replacement.
- * @param baseline - active process-local attempts for a follow opening.
- * @returns the same durable window; baseline seqs suppress later duplicate live appends.
+ * @param entries - durable entries in the replacement window.
+ * @param baseline - compact prefix for an Assistant attempt that is still live.
+ * @returns immediately visible durable and reconstructed transient entries, with an active settlement withheld.
*/
replace(
entries: readonly SessionEventLikeEntry[],
baseline?: SessionAssistantStreamBaseline,
): readonly SessionEventLikeEntry[] {
- this.pendingChunks.clear()
- this.pendingMessages.clear()
- this.attempts.clear()
- if (baseline !== undefined) {
- for (const attempt of baseline.attempts) {
- this.attempts.set(String(attempt.attemptId), {
- startedTime: attempt.startedTime,
- turn: attempt.turn,
- step: attempt.step,
- legacyChunkSeqs: new Set(attempt.legacyChunkSeqs),
- nextIndex: attempt.chunks.length,
- })
+ this.pending.clear()
+ this.transientInGap = 0
+ this.activeAttempt = undefined
+ const opening = baseline?.activeAttempt
+ if (opening !== undefined) {
+ this.activeAttempt = {
+ attemptId: String(opening.attemptId),
+ startedTime: opening.startedTime,
+ turn: opening.turn,
+ step: opening.step,
+ nextIndex: opening.nextIndex,
}
}
- const visible = entries
+ const pending = opening === undefined
+ ? undefined
+ : entries.findLast(entry => entry.type === 'event'
+ && this.attemptForSettlement(entry.event) !== undefined)
+ if (pending?.type === 'event') this.pending.set(pending.event.seq, pending)
+ const visible: SessionEventLikeEntry[] = pending === undefined
+ ? [...entries]
+ : entries.filter(entry => entry !== pending)
this.publishedSeqs = new Set(visible.map(entry => entry.event.seq))
+ this.durableCursor = visible.reduce((cursor, entry) => Math.max(cursor, entry.event.seq), -1)
+ if (opening !== undefined) {
+ for (const [index, member] of expandAssistantStream(
+ opening.stream as unknown as readonly AssistantStreamRecord[],
+ ).entries()) {
+ this.transientInGap += 1
+ visible.push({
+ type: 'transient',
+ event: {
+ type: 'assistant/live-chunk',
+ seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1),
+ time: member.time,
+ data: {
+ attemptId: opening.attemptId,
+ turn: opening.turn,
+ step: opening.step,
+ chunk: member.chunk,
+ },
+ },
+ })
+ if (index + 1 >= opening.nextIndex) break
+ }
+ }
return visible
}
/**
- * Stage one durable tail event when an active attempt owns its publication.
- * @param entry - next cursor-validated durable event.
- * @returns the entry for immediate publication, or undefined while staged.
+ * Stage one durable v2 settlement while its matching live attempt is open.
+ * @param entry - newly followed durable entry.
+ * @returns a publication decision, or `undefined` when no entry becomes visible.
*/
acceptDurable(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
const event = entry.event
- if (event.type === 'assistant/chunk') {
- const attempt = this.attemptFor(event.data.turn, event.data.step)
- if (attempt === undefined) return this.publish(entry)
- if (attempt.legacyChunkSeqs.has(event.seq)) return this.publish(entry)
- this.pendingChunks.set(event.seq, entry)
- return undefined
- }
- if (event.type === 'assistant/message') {
- if (event.surfaceOp !== 'append') return this.publish(entry)
- const attempt = this.attemptFor(event.data.turn, event.data.step)
- if (attempt === undefined) return this.publish(entry)
- this.pendingMessages.set(positionKey(event.data.turn, event.data.step), entry)
+ this.durableCursor = Math.max(this.durableCursor, event.seq)
+ this.transientInGap = 0
+ if (this.attemptForSettlement(event) !== undefined) {
+ this.pending.set(event.seq, entry)
return undefined
}
return this.publish(entry)
}
/**
- * Fold one validated transient frame and release its matching durable event.
- * @param frame - next dense process-local frame.
- * @returns one durable event whose Web publication commits at this frame.
+ * Fold one dense transient frame and release its named durable settlement.
+ * @param frame - next Assistant stream frame received by the follow connection.
+ * @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible.
*/
acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
switch (frame.type) {
case 'start':
- this.attempts.set(String(frame.attemptId), {
+ this.pending.clear()
+ this.activeAttempt = {
+ attemptId: String(frame.attemptId),
startedTime: frame.startedTime,
turn: frame.turn,
step: frame.step,
- legacyChunkSeqs: new Set(),
nextIndex: 0,
- })
+ }
return undefined
case 'chunk': {
- const attempt = this.attempts.get(String(frame.attemptId))
- if (attempt === undefined || frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
+ const attempt = this.activeAttempt
+ if (attempt === undefined
+ || attempt.attemptId !== String(frame.attemptId)
+ || frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
attempt.nextIndex += 1
- attempt.legacyChunkSeqs.add(frame.legacyChunkSeq)
- if (this.publishedSeqs.has(frame.legacyChunkSeq)) return undefined
- const entry = this.pendingChunks.get(frame.legacyChunkSeq)
- if (entry === undefined) return { type: 'rebaseline' }
- this.pendingChunks.delete(frame.legacyChunkSeq)
- return this.publish(entry)
+ this.transientInGap += 1
+ return {
+ type: 'transient',
+ entry: {
+ type: 'transient',
+ event: {
+ type: 'assistant/live-chunk',
+ seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1),
+ time: frame.time,
+ data: {
+ attemptId: frame.attemptId,
+ turn: attempt.turn,
+ step: attempt.step,
+ chunk: frame.chunk as never,
+ },
+ },
+ },
+ }
}
case 'end': {
- const attempt = this.attempts.get(String(frame.attemptId))
- this.attempts.delete(String(frame.attemptId))
- if (attempt === undefined) return { type: 'rebaseline' }
+ const attempt = this.activeAttempt
+ this.activeAttempt = undefined
+ if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) {
+ return { type: 'rebaseline' }
+ }
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
- if (!sameSeqs([...attempt.legacyChunkSeqs], frame.legacyChunkSeqs)) {
+ if (frame.outcome.kind === 'abandoned') {
+ return this.pending.size === 0 ? undefined : { type: 'rebaseline' }
+ }
+ if (this.publishedSeqs.has(frame.outcome.seq)) return undefined
+ const entry = this.pending.get(frame.outcome.seq)
+ if (entry === undefined
+ || entry.event.type !== frame.outcome.eventType
+ || entry.event.data.turn !== attempt.turn
+ || entry.event.data.step !== attempt.step) {
return { type: 'rebaseline' }
}
- const key = positionKey(attempt.turn, attempt.step)
- const entry = this.pendingMessages.get(key)
- if (entry === undefined) {
- return frame.outcome === 'aborted' ? undefined : { type: 'rebaseline' }
- }
- if (entry.event.type !== 'assistant/message') return { type: 'rebaseline' }
- const sourceEventSeqs = entry.event.sourceEventSeqs
- if (sourceEventSeqs === undefined || !sameSeqs(sourceEventSeqs, frame.legacyChunkSeqs)) {
- return { type: 'rebaseline' }
- }
- this.pendingMessages.delete(key)
+ this.pending.delete(frame.outcome.seq)
return this.publish(entry)
}
}
}
- private attemptFor(turn: number, step: number): ActiveAttempt | undefined {
- return [...this.attempts.values()].find(attempt => (
- attempt.turn === turn && attempt.step === step
- ))
+ private attemptForSettlement(
+ event: SessionLiveEventEntry['event'],
+ ): ActiveAttempt | undefined {
+ const attempt = this.activeAttempt
+ if (attempt === undefined
+ || (event.type !== 'assistant/message' && event.type !== 'assistant/attempt')
+ || (event.type === 'assistant/message' && event.surfaceOp !== 'append')
+ || event.time < attempt.startedTime
+ || attempt.turn !== event.data.turn
+ || attempt.step !== event.data.step) return undefined
+ return attempt
}
private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
diff --git a/packages/api/session-controller/src/client/sessions/history-records.ts b/packages/api/session-controller/src/client/sessions/history-records.ts
index 77ed4ad980..9a7f14cfa6 100644
--- a/packages/api/session-controller/src/client/sessions/history-records.ts
+++ b/packages/api/session-controller/src/client/sessions/history-records.ts
@@ -18,7 +18,7 @@ export function historyEntries(
/**
* Read the first logical sequence represented by one wire record.
- * @param record - validated scalar event or packed Assistant delta run.
+ * @param record - validated Session event.
* @returns inclusive first Session sequence.
*/
export function historyRecordFirstSeq(record: SessionHistoryRecord): number {
@@ -27,13 +27,9 @@ export function historyRecordFirstSeq(record: SessionHistoryRecord): number {
/**
* Read the final logical sequence represented by one wire record.
- * @param record - validated scalar event or packed Assistant delta run.
+ * @param record - validated Session event.
* @returns inclusive final Session sequence.
*/
export function historyRecordLastSeq(record: SessionHistoryRecord): number {
- if (record.type === 'event') return record.event.seq
- const length = record.event.type === 'chunkrow/tool-call-chunks'
- ? record.event.data.args.length
- : record.event.data.texts.length
- return record.event.seq + length - 1
+ return record.event.seq
}
diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts
index d8af64b0c5..f1776477e9 100644
--- a/packages/api/session-controller/src/client/sessions/session.ts
+++ b/packages/api/session-controller/src/client/sessions/session.ts
@@ -651,7 +651,7 @@ export class Session implements SessionFace {
// attempts makes a held notification reopen follow once for an atomic
// page/baseline pair instead of applying it to an unrelated repair cut.
const visible = this.assistantStream.replace(entries, assistantStream)
- this.baseSeq = SessionLogOffset(visible[0]?.event.seq ?? 0)
+ this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
this.hasMore = hasMore
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
@@ -670,6 +670,9 @@ export class Session implements SessionFace {
}
if (result?.type === 'publish' && this.appendLive(result.entry)) {
this.notifier.markDirty()
+ } else if (result?.type === 'transient') {
+ this.eventSource.append(result.entry)
+ this.notifier.markDirty()
}
}
diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts
index 71f6cf1769..8ba52fb809 100644
--- a/packages/api/session-controller/src/client/transport.ts
+++ b/packages/api/session-controller/src/client/transport.ts
@@ -66,13 +66,6 @@ function toSessionJournalChange(
case 'prepend':
return { ...change, entries: historyEntries(change.entries) }
case 'append': {
- if (change.entry.type !== 'event') {
- throw new RemoteError(
- 'gateway/internal',
- 'session live stream emitted a packed history record',
- {},
- )
- }
return {
type: 'append',
entry: change.entry as unknown as SessionLiveEventEntry,
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index 0e8015f79c..d9fc23e1f1 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -7,7 +7,7 @@ import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/
import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import {
- ReasoningEffortId, createUserMessage, freezeMessage,
+ ReasoningEffortId, createUserMessage, expandAssistantStream, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
@@ -525,7 +525,6 @@ function imageInEvent(
readonly content?: unknown
readonly message?: { readonly content?: unknown }
readonly inserted?: readonly { readonly content?: unknown }[]
- readonly chunk?: { readonly type?: unknown; readonly block?: unknown }
}
const direct = imageBlockIn(data.content, match)
if (direct !== undefined) return direct
@@ -535,9 +534,14 @@ function imageInEvent(
const found = imageBlockIn(inserted.content, match)
if (found !== undefined) return found
}
- return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end'
- ? imageBlockIn([data.chunk.block], match)
- : undefined
+ if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
+ for (const { chunk } of expandAssistantStream(event.data.stream)) {
+ if (chunk.type !== 'block-end') continue
+ const found = imageBlockIn([chunk.block], match)
+ if (found !== undefined) return found
+ }
+ }
+ return undefined
}
function referencedImage(
diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts
index 576b4dd627..74faa6d3a7 100644
--- a/packages/api/session-controller/src/history.ts
+++ b/packages/api/session-controller/src/history.ts
@@ -8,7 +8,6 @@ import {
SessionLogOffset,
SessionSeq,
} from '@deepseek-ai/dsh-session'
-import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type {
SessionEvent,
SessionHeader,
@@ -23,7 +22,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
SessionAddress,
SessionAssistantStreamFrame,
- SessionChunkRun,
SessionEventEntry,
SessionFollowRequest,
SessionFollowFrame,
@@ -183,7 +181,7 @@ export class SessionHistoryController {
snapshotCursor = cursor
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
const assistantStream = request.assistantStream === true
- ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0, attempts: [] }
+ ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 }
: undefined
// The accumulator snapshot and this watermark are synchronous. Frames
// through the cut are represented or superseded by that baseline,
@@ -192,7 +190,7 @@ export class SessionHistoryController {
const assistantStreamOrdinalCut = assistantStreamOrdinal
yield {
type: 'snapshot',
- header: wireHeader(source.header, source.inheritedEventCount),
+ header: wireHeader(source.header),
cursor,
records: pageRecords(page.events),
hasMore: page.hasMore,
@@ -402,16 +400,9 @@ function paginate(
return { events: events.slice(cut, end), hasMore: cut > 0 }
}
-/** Translate logical Session metadata to the unchanged v0 browser wire. */
-function wireHeader(
- header: SessionHeader,
- inheritedEventCount: SessionLogOffsetType,
-): SessionWireHeader {
- const { isSeeded, ...wire } = header
- return {
- ...wire,
- ...isSeeded ? { seedLength: inheritedEventCount } : {},
- }
+/** Translate current logical Session metadata to the browser wire. */
+function wireHeader(header: SessionHeader): SessionWireHeader {
+ return { ...header }
}
function entryFor(event: SessionEvent): SessionEventEntry {
@@ -422,29 +413,7 @@ function entryFor(event: SessionEvent): SessionEventEntry {
}
}
-function chunkEntryFor(row: ChunkRow): SessionChunkRun {
- switch (row.type) {
- case 'text-chunks':
- return {
- type: 'chunks',
- event: { type: 'chunkrow/text-chunks', seq: row.seq0, time: row.time0, data: row.data },
- }
- case 'reasoning-chunks':
- return {
- type: 'chunks',
- event: { type: 'chunkrow/reasoning-chunks', seq: row.seq0, time: row.time0, data: row.data },
- }
- case 'tool-call-chunks':
- return {
- type: 'chunks',
- event: { type: 'chunkrow/tool-call-chunks', seq: row.seq0, time: row.time0, data: row.data },
- }
- }
-}
-
/** Encode one bounded logical page without changing its pagination cut. */
function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] {
- return packChunkRuns(events).map(record => isChunkRow(record)
- ? chunkEntryFor(record)
- : entryFor(record))
+ return events.map(entryFor)
}
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index 53b94f2fe6..57c8ecef27 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/packages/api/session-controller/src/types.ts
@@ -5,8 +5,7 @@ import type {
} from '@deepseek-ai/dsh-attachment'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
@@ -384,15 +383,15 @@ export interface SessionEventEntry {
readonly event: SessionWireEvent
}
-/** v0-compatible Session metadata carried on the browser wire. */
+/** Current logical Session metadata carried on the browser wire. */
export interface SessionWireHeader {
readonly version: number
readonly id: SessionId
readonly createdAt: number
readonly cwd?: string
readonly parentSession?: SessionId
- /** Exact inherited prefix length; absent for an unseeded Session. */
- readonly seedLength?: number
+ /** Whether the Session contains a fork-inherited prefix. */
+ readonly isSeeded: boolean
readonly origin?: 'subagent'
readonly delegationDepth?: number
readonly agentPreset?: string
@@ -403,24 +402,8 @@ export type SessionWireSurfaceOp =
| 'append'
| { readonly op: 'replace'; readonly start: number; readonly end: number }
-/** Event-shaped wire representation of one packed chunk row. */
-export type ChunkRowEvent = {
- [Kind in ChunkRow['type']]: {
- readonly type: `chunkrow/${Kind}`
- readonly seq: number
- readonly time: number
- readonly data: Extract['data']
- }
-}[ChunkRow['type']]
-
-/** One lossless run of consecutive Assistant delta events in a history page. */
-export interface SessionChunkRun {
- readonly type: 'chunks'
- readonly event: ChunkRowEvent
-}
-
-/** One history-page record: a raw event or a packed Assistant delta run. */
-export type SessionHistoryRecord = SessionEventEntry | SessionChunkRun
+/** One history-page record. V2 embeds compact Assistant streams inside events. */
+export type SessionHistoryRecord = SessionEventEntry
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
export interface SessionWireEvent {
@@ -457,15 +440,16 @@ export interface SessionAssistantStreamAttempt {
readonly startedTime: number
readonly turn: number
readonly step: number
- readonly chunks: readonly JsonValue[]
- /** Exact durable v1 chunk records already represented by {@link chunks}. */
- readonly legacyChunkSeqs: readonly number[]
+ /** Dense position expected for the next live chunk frame. */
+ readonly nextIndex: number
+ /** Compact detached stream accumulated at this opening revision. */
+ readonly stream: readonly JsonValue[]
}
/** Complete process-local assistant state at one follow opening. */
export interface SessionAssistantStreamBaseline {
readonly revision: number
- readonly attempts: readonly SessionAssistantStreamAttempt[]
+ readonly activeAttempt?: SessionAssistantStreamAttempt
}
/** Browser wire form of one process-local assistant frame. */
@@ -483,8 +467,8 @@ export type SessionAssistantStreamFrame =
readonly attemptId: LlmAttemptId
readonly revision: number
readonly index: number
+ readonly time: number
readonly chunk: JsonValue
- readonly legacyChunkSeq: number
}
| {
readonly type: 'end'
@@ -492,8 +476,13 @@ export type SessionAssistantStreamFrame =
readonly revision: number
/** Number of chunk frames represented by this terminal marker. */
readonly index: number
- readonly outcome: 'committed' | 'aborted'
- readonly legacyChunkSeqs: readonly number[]
+ readonly outcome:
+ | {
+ readonly kind: 'committed'
+ readonly eventType: 'assistant/message' | 'assistant/attempt'
+ readonly seq: number
+ }
+ | { readonly kind: 'abandoned' }
}
/** One contiguous backwards page of a Session log. */
diff --git a/packages/api/session-controller/tests/assistant-stream.host.spec.ts b/packages/api/session-controller/tests/assistant-stream.host.spec.ts
new file mode 100644
index 0000000000..9e658ed61a
--- /dev/null
+++ b/packages/api/session-controller/tests/assistant-stream.host.spec.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
+import { SessionAssistantStreamAccumulator } from '../src/assistant-stream.ts'
+
+describe('SessionAssistantStreamAccumulator', () => {
+ it('replaces stale lifecycles, rejects frame gaps, and caches each baseline', () => {
+ const accumulator = new SessionAssistantStreamAccumulator()
+ const empty = accumulator.snapshot()
+ expect(accumulator.snapshot()).toBe(empty)
+
+ accumulator.accept({
+ type: 'start', attemptId: LlmAttemptId('stale'), revision: 2,
+ startedTime: 1, turn: 1, step: 1,
+ })
+ expect(accumulator.snapshot()).toEqual({ revision: 2 })
+
+ accumulator.accept({
+ type: 'start', attemptId: LlmAttemptId('current'), revision: 1,
+ startedTime: 2, turn: 2, step: 3,
+ })
+ expect(accumulator.snapshot()).toMatchObject({
+ revision: 1,
+ activeAttempt: { attemptId: 'current', turn: 2, step: 3, nextIndex: 0, stream: [] },
+ })
+
+ accumulator.accept({
+ type: 'chunk', attemptId: LlmAttemptId('other'), revision: 2, index: 0,
+ time: 4, chunk: { type: 'text-delta', index: 0, text: 'lost' },
+ })
+ expect(accumulator.snapshot()).toEqual({ revision: 2 })
+
+ accumulator.accept({
+ type: 'start', attemptId: LlmAttemptId('settled'), revision: 3,
+ startedTime: 3, turn: 2, step: 4,
+ })
+ accumulator.accept({
+ type: 'chunk', attemptId: LlmAttemptId('settled'), revision: 4, index: 0,
+ time: 5, chunk: { type: 'text-delta', index: 0, text: 'ok' },
+ })
+ const active = accumulator.snapshot()
+ expect(active).toMatchObject({
+ revision: 4,
+ activeAttempt: {
+ attemptId: 'settled', nextIndex: 1,
+ stream: [{ type: 'text-chunks', time0: 5, index: 0, dt: [], texts: ['ok'] }],
+ },
+ })
+ expect(accumulator.snapshot()).toBe(active)
+
+ accumulator.accept({
+ type: 'end', attemptId: LlmAttemptId('settled'), revision: 5, index: 1,
+ outcome: { kind: 'abandoned' },
+ })
+ expect(accumulator.snapshot()).toEqual({ revision: 5 })
+ })
+})
diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
index 07ba0a6887..4db2f8cbfc 100644
--- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
+++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
@@ -178,6 +178,7 @@ describe('Session attachment authorization', () => {
{ ...event('assistant/message', SessionSeq(1), {
turn: 1,
step: 1,
+ stream: [],
message: createAssistantMessage({
content: [{ type: 'image', attachment: message }],
source: { provider: 'fixture', model: 'fixture' },
@@ -191,10 +192,30 @@ describe('Session attachment authorization', () => {
source: { kind: 'user' },
})],
}),
- event('assistant/chunk', SessionSeq(3), {
+ event('assistant/attempt', SessionSeq(3), {
turn: 1,
step: 1,
- chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
+ stream: [
+ {
+ type: 'chunk',
+ time: 3,
+ chunk: { type: 'block-start', index: 0, blockType: 'text' },
+ },
+ {
+ type: 'chunk',
+ time: 3,
+ chunk: { type: 'block-end', index: 0, block: { type: 'text', text: '' } },
+ },
+ ],
+ }),
+ event('assistant/attempt', SessionSeq(4), {
+ turn: 1,
+ step: 1,
+ stream: [{
+ type: 'chunk',
+ time: 4,
+ chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
+ }],
}),
]
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
diff --git a/packages/api/session-controller/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts
index 17cd7775fb..59caa3de59 100644
--- a/packages/api/session-controller/tests/event-script.client.ts
+++ b/packages/api/session-controller/tests/event-script.client.ts
@@ -27,13 +27,18 @@ export const ev = {
}) }),
stepStart: (seq: SessionSeq, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/start', data: { turn, step } }),
- chunkStart: (seq: SessionSeq, turn: number, step = 0, index = 0): SessionEvent =>
- at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index, blockType: 'text' } } }),
- chunkText: (seq: SessionSeq, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
- at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
assistant: (seq: SessionSeq, turn: number, body: string, step = 0): SessionEvent =>
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
turn, step,
+ stream: [
+ { type: 'chunk', time: 1_700_000_000_000 + seq, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
+ { type: 'text-chunks', time0: 1_700_000_000_000 + seq, index: 0, dt: [], texts: [body] },
+ {
+ type: 'chunk', time: 1_700_000_000_000 + seq,
+ chunk: { type: 'block-end', index: 0, block: { type: 'text', text: body } },
+ },
+ { type: 'chunk', time: 1_700_000_000_000 + seq, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+ ],
message: createMessage({
role: 'assistant',
content: text(body),
diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts
index f1772f41fc..c52e4a3553 100644
--- a/packages/api/session-controller/tests/fake-api.client.ts
+++ b/packages/api/session-controller/tests/fake-api.client.ts
@@ -161,7 +161,6 @@ export class FakeApiClient {
}
assistantStreamBaseline: SessionAssistantStreamBaseline = {
revision: 0,
- attempts: [],
}
workspaceBaseline: Extract['value'] = {
items: [],
@@ -393,9 +392,10 @@ export class FakeApiClient {
yield {
type: 'snapshot',
header: {
- version: 1,
+ version: 2,
id: sessionId,
createdAt: 0,
+ isSeeded: false,
...(request.address.kind === 'subagent'
? { origin: 'subagent' as const, parentSession: request.address.parentSessionId }
: {}),
diff --git a/packages/api/session-controller/tests/history-records.client.spec.ts b/packages/api/session-controller/tests/history-records.client.spec.ts
index e8adbfcb1d..e97924435d 100644
--- a/packages/api/session-controller/tests/history-records.client.spec.ts
+++ b/packages/api/session-controller/tests/history-records.client.spec.ts
@@ -1,4 +1,4 @@
-/** Packed history records become one event-shaped Client value per wire record. */
+/** V2 history records become one event-shaped Client value per wire record. */
import { describe, expect, it } from 'vitest'
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
@@ -26,53 +26,69 @@ describe('Session history record projection', () => {
expect(historyRecordLastSeq(ordinary)).toBe(7)
})
- it('retains one packed text row without copying or reshaping it', () => {
- const packed: SessionHistoryRecord = {
- type: 'chunks',
+ it('retains one message with an embedded compact text stream', () => {
+ const message: SessionHistoryRecord = {
+ type: 'event',
event: {
- type: 'chunkrow/text-chunks',
+ type: 'assistant/message',
seq: 11,
time: 20,
- data: { turn: 1, step: 2, index: 0, dt: [1, 2, 3], texts: ['a', 'b', 'c', 'd'] },
+ data: {
+ turn: 1,
+ step: 2,
+ message: {
+ id: 'message-1',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'abcd' }],
+ source: { kind: 'model', provider: 'fixture', model: 'fixture-v2' },
+ },
+ stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [1, 2, 3], texts: ['a', 'b', 'c', 'd'] }],
+ },
},
}
- const [entry] = historyEntries([packed])
- if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
+ const [entry] = historyEntries([message])
+ if (entry?.type !== 'event') throw new Error('expected v2 history entry')
const { event } = entry
- expect(entry).toBe(packed)
- expect(event).toBe(packed.event)
- expect(historyRecordFirstSeq(packed)).toBe(11)
+ expect(entry).toBe(message)
+ expect(event).toBe(message.event)
+ expect(historyRecordFirstSeq(message)).toBe(11)
expect(event.time).toBe(20)
- expect(historyRecordLastSeq(packed)).toBe(14)
+ expect(historyRecordLastSeq(message)).toBe(11)
})
- it('preserves a packed tool-call row and optional-name absence', () => {
- const packed: SessionHistoryRecord = {
- type: 'chunks',
+ it('preserves an attempt stream and optional tool-name absence', () => {
+ const attempt: SessionHistoryRecord = {
+ type: 'event',
event: {
- type: 'chunkrow/tool-call-chunks',
+ type: 'assistant/attempt',
seq: 20,
time: 200,
data: {
turn: 2,
step: 4,
- index: 1,
- id: ToolCallId('call-1'),
- dt: [2, 3],
- args: ['', '{"x":', '1}'],
+ stream: [{
+ type: 'tool-call-chunks',
+ time0: 200,
+ index: 1,
+ id: ToolCallId('call-1'),
+ dt: [2, 3],
+ args: ['', '{"x":', '1}'],
+ }],
},
},
}
- const [entry] = historyEntries([packed])
- if (entry?.type !== 'chunks') throw new Error('expected packed history entry')
+ const [entry] = historyEntries([attempt])
+ if (entry?.type !== 'event') throw new Error('expected v2 history entry')
const { event } = entry
- if (event.type !== 'chunkrow/tool-call-chunks') throw new Error('expected packed history event')
- expect(event).toBe(packed.event)
- expect(Object.hasOwn(event.data, 'name')).toBe(false)
- expect(historyRecordLastSeq(packed)).toBe(22)
+ if (event.type !== 'assistant/attempt') throw new Error('expected Assistant attempt event')
+ expect(event).toBe(attempt.event)
+ const [record] = event.data.stream
+ expect(record).toMatchObject({ type: 'tool-call-chunks', id: 'call-1' })
+ expect(Object.hasOwn(record as object, 'name')).toBe(false)
+ expect(historyRecordLastSeq(attempt)).toBe(20)
})
})
diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
index eb22bbdc1c..d8af94f20f 100644
--- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts
+++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
@@ -3,17 +3,11 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
-import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session'
-import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
+import SessionStore from '@deepseek-ai/dsh-session'
import { LlmAttemptId, ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
-import type {
- ChunkRowEvent,
- SessionFollowFrame,
- SessionPage,
- SessionWireEvent,
-} from '@deepseek-ai/dsh-api-session-controller/types'
+import type { SessionFollowFrame, SessionPage, SessionWireEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts'
/** Append a production-shaped human prompt to the session surface. */
@@ -33,6 +27,7 @@ function appendAssistantText(session: Session, text: string, step: number): Sess
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: [text] }],
}, { surfaceOp: 'append' })
}
@@ -94,25 +89,87 @@ async function disposeFollow(
await ctx.fiber.dispose()
}
-/** Expand packed page records for assertions over the logical journal. */
+/** Read scalar v2 page records for assertions over the logical journal. */
function pageEvents(page: SessionPage): SessionWireEvent[] {
- return page.records.flatMap(record => record.type === 'event'
- ? [record.event]
- : decodeStorageRecord(chunkRow(record.event)).map(event => event as unknown as SessionWireEvent))
-}
-
-function chunkRow(event: ChunkRowEvent): ChunkRow {
- switch (event.type) {
- case 'chunkrow/text-chunks':
- return { type: 'text-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
- case 'chunkrow/reasoning-chunks':
- return { type: 'reasoning-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
- case 'chunkrow/tool-call-chunks':
- return { type: 'tool-call-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
- }
+ return page.records.map(record => record.event)
}
describe('Session history raw journal', () => {
+ it('opens an empty opted-in Assistant baseline before any live attempt exists', async () => {
+ const { ctx } = await harness()
+ const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+ const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+ const abort = new AbortController()
+ const iterator = history.follow({
+ address: { kind: 'session', sessionId: session.id },
+ assistantStream: true,
+ }, abort.signal)[Symbol.asyncIterator]()
+
+ await expect(iterator.next()).resolves.toMatchObject({
+ done: false,
+ value: { type: 'snapshot', assistantStream: { revision: 0 } },
+ })
+ abort.abort()
+ await iterator.next()
+ await ctx.fiber.dispose()
+ })
+
+ it('filters foreign and opening-baseline frames buffered during the source observation', async () => {
+ const { ctx } = await harness()
+ const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+ const agent = { id: session.id, session, status: 'running', ctx } as Agent
+ const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
+ const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
+ const entered = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (...args) => {
+ entered.resolve(undefined)
+ await release.promise
+ return originalObserve(...args)
+ })
+ const abort = new AbortController()
+ const iterator = history.follow({
+ address: { kind: 'session', sessionId: session.id },
+ assistantStream: true,
+ }, abort.signal)[Symbol.asyncIterator]()
+ const opening = iterator.next()
+ await entered.promise
+
+ const attemptId = LlmAttemptId('buffered-opening-attempt')
+ ctx.emit('agent/assistant-stream', {
+ agent,
+ frame: { type: 'start', attemptId, revision: 1, startedTime: 1, turn: 1, step: 1 },
+ })
+ ctx.emit('agent/assistant-stream', {
+ agent,
+ frame: {
+ type: 'chunk', attemptId, revision: 2, index: 0,
+ time: 2, chunk: { type: 'text-delta', index: 0, text: 'buffered' },
+ },
+ })
+ const foreign = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+ ctx.emit('agent/assistant-stream', {
+ agent: { id: foreign.id, session: foreign, status: 'running', ctx } as Agent,
+ frame: {
+ type: 'start', attemptId: LlmAttemptId('foreign-attempt'), revision: 1,
+ startedTime: 1, turn: 1, step: 1,
+ },
+ })
+ release.resolve(undefined)
+ await expect(opening).resolves.toMatchObject({
+ done: false,
+ value: { type: 'snapshot', assistantStream: { revision: 2 } },
+ })
+
+ const next = iterator.next()
+ const durable = session.append('turn/start', { turn: 1 })
+ await expect(next).resolves.toEqual({ done: false, value: { type: 'event', event: durable } })
+ abort.abort()
+ await iterator.next()
+ observe.mockRestore()
+ await ctx.fiber.dispose()
+ })
+
it('opens an opted-in assistant baseline and preserves mixed live FIFO order', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
@@ -126,12 +183,10 @@ describe('Session history raw journal', () => {
type: 'start', attemptId, revision: 1, startedTime: 100,
turn: 1, step: 1,
})
- const firstChunk = session.append('assistant/chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' },
- })
+ const firstChunk = { type: 'text-delta', index: 0, text: 'a' } as const
emit({
type: 'chunk', attemptId, revision: 2, index: 0,
- chunk: firstChunk.data.chunk, legacyChunkSeq: firstChunk.seq,
+ time: 1, chunk: firstChunk,
})
const abort = new AbortController()
const iterator = history.follow({
@@ -145,35 +200,29 @@ describe('Session history raw journal', () => {
type: 'snapshot',
assistantStream: {
revision: 2,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 100,
turn: 1,
step: 1,
- chunks: [firstChunk.data.chunk],
- legacyChunkSeqs: [firstChunk.seq],
- }],
+ nextIndex: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }],
+ },
},
},
})
- const nextChunk = session.append('assistant/chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' },
- })
const nextFrame: AssistantStreamFrame = {
type: 'chunk', attemptId, revision: 3, index: 1,
- chunk: nextChunk.data.chunk, legacyChunkSeq: nextChunk.seq,
+ time: 2, chunk: { type: 'text-delta', index: 0, text: 'b' },
}
emit(nextFrame)
const message = appendAssistantText(session, 'ab', 1)
const endFrame: AssistantStreamFrame = {
- type: 'end', attemptId, revision: 4, index: 2, outcome: 'committed',
- legacyChunkSeqs: [firstChunk.seq, nextChunk.seq],
+ type: 'end', attemptId, revision: 4, index: 2,
+ outcome: { kind: 'committed', eventType: 'assistant/message', seq: message.seq },
}
emit(endFrame)
- await expect(iterator.next()).resolves.toEqual({
- done: false, value: { type: 'event', event: nextChunk },
- })
await expect(iterator.next()).resolves.toEqual({
done: false, value: { type: 'assistant-stream', frame: nextFrame },
})
@@ -201,14 +250,12 @@ describe('Session history raw journal', () => {
turn: 1, step: 1,
},
})
- const oldChunk = session.append('assistant/chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'old' },
- })
+ const oldChunk = { type: 'text-delta', index: 0, text: 'old' } as const
ctx.emit('agent/assistant-stream', {
agent,
frame: {
type: 'chunk', attemptId, revision: 2, index: 0,
- chunk: oldChunk.data.chunk, legacyChunkSeq: oldChunk.seq,
+ time: 101, chunk: oldChunk,
},
})
const abort = new AbortController()
@@ -224,14 +271,14 @@ describe('Session history raw journal', () => {
type: 'snapshot',
assistantStream: {
revision: 2,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 100,
turn: 1,
step: 1,
- chunks: [oldChunk.data.chunk],
- legacyChunkSeqs: [oldChunk.seq],
- }],
+ nextIndex: 1,
+ stream: [{ type: 'text-chunks', time0: 101, index: 0, dt: [], texts: ['old'] }],
+ },
},
},
})
@@ -265,14 +312,12 @@ describe('Session history raw journal', () => {
turn: 1, step: 1,
},
})
- const chunk = session.append('assistant/chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'after gap' },
- })
+ const chunk = { type: 'text-delta', index: 0, text: 'after gap' } as const
ctx.emit('agent/assistant-stream', {
agent,
frame: {
type: 'chunk', attemptId, revision: 3, index: 0,
- chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
+ time: 101, chunk,
},
})
const abort = new AbortController()
@@ -286,7 +331,7 @@ describe('Session history raw journal', () => {
done: false,
value: {
type: 'snapshot',
- assistantStream: { revision: 3, attempts: [] },
+ assistantStream: { revision: 3 },
},
})
} finally {
@@ -307,14 +352,12 @@ describe('Session history raw journal', () => {
turn: 1, step: 1,
},
})
- const chunk = session.append('assistant/chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'out of order' },
- })
+ const chunk = { type: 'text-delta', index: 0, text: 'out of order' } as const
ctx.emit('agent/assistant-stream', {
agent,
frame: {
type: 'chunk', attemptId, revision: 2, index: 1,
- chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
+ time: 101, chunk,
},
})
const abort = new AbortController()
@@ -328,7 +371,7 @@ describe('Session history raw journal', () => {
done: false,
value: {
type: 'snapshot',
- assistantStream: { revision: 2, attempts: [] },
+ assistantStream: { revision: 2 },
},
})
} finally {
@@ -364,7 +407,7 @@ describe('Session history raw journal', () => {
const first = await firstIterator.next()
if (first.done || first.value.type !== 'snapshot') throw new Error('first follow did not open')
const baseline = first.value.assistantStream
- expect(baseline).toMatchObject({ revision: 1, attempts: [{ attemptId }] })
+ expect(baseline).toMatchObject({ revision: 1, activeAttempt: { attemptId } })
const second = await secondIterator.next()
if (second.done || second.value.type !== 'snapshot') throw new Error('second follow did not open')
expect(second.value.assistantStream).toEqual(baseline)
@@ -392,7 +435,7 @@ describe('Session history raw journal', () => {
done: false,
value: {
type: 'snapshot',
- assistantStream: { revision: 0, attempts: [] },
+ assistantStream: { revision: 0 },
},
})
} finally {
@@ -466,7 +509,7 @@ describe('Session history raw journal', () => {
done: false,
value: {
type: 'snapshot',
- assistantStream: { revision: 1, attempts: [{ attemptId: frame.attemptId }] },
+ assistantStream: { revision: 1, activeAttempt: { attemptId: frame.attemptId } },
},
})
@@ -484,74 +527,6 @@ describe('Session history raw journal', () => {
}
})
- it('delivers a baseline-framed chunk whose durable event lands after the opening snapshot', async () => {
- const { ctx } = await harness()
- const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
- const agent = { id: session.id, session, status: 'running', ctx } as Agent
- const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
- const attemptId = LlmAttemptId('opening-durable-cut-attempt')
- ctx.emit('agent/assistant-stream', {
- agent,
- frame: {
- type: 'start', attemptId, revision: 1, startedTime: 100,
- turn: 1, step: 1,
- },
- })
- const observationCaptured = Promise.withResolvers()
- const releaseObservation = Promise.withResolvers()
- const originalObserve = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
- const observe = vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation(async (sessionId, options) => {
- const observation = await originalObserve(sessionId, options)
- observationCaptured.resolve(undefined)
- await releaseObservation.promise
- return observation
- })
- const abort = new AbortController()
- const iterator = history.follow({
- address: { kind: 'session', sessionId: session.id },
- assistantStream: true,
- }, abort.signal)[Symbol.asyncIterator]()
-
- try {
- const opening = iterator.next()
- await observationCaptured.promise
- const chunk = session.append('assistant/chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'cut-safe' },
- })
- ctx.emit('agent/assistant-stream', {
- agent,
- frame: {
- type: 'chunk', attemptId, revision: 2, index: 0,
- chunk: chunk.data.chunk, legacyChunkSeq: chunk.seq,
- },
- })
- const after = session.append('turn/start', { turn: 2 })
- releaseObservation.resolve(undefined)
-
- await expect(opening).resolves.toMatchObject({
- done: false,
- value: {
- type: 'snapshot',
- records: [],
- assistantStream: {
- revision: 2,
- attempts: [{ attemptId, legacyChunkSeqs: [chunk.seq] }],
- },
- },
- })
- await expect(iterator.next()).resolves.toEqual({
- done: false, value: { type: 'event', event: chunk },
- })
- await expect(iterator.next()).resolves.toEqual({
- done: false, value: { type: 'event', event: after },
- })
- } finally {
- releaseObservation.resolve(undefined)
- observe.mockRestore()
- await disposeFollow(ctx, iterator, abort)
- }
- })
-
it('does not release an old-lifecycle frame after the opening baseline resets to revision one', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
@@ -582,14 +557,12 @@ describe('Session history raw journal', () => {
try {
const opening = iterator.next()
await observationStarted.promise
- const oldChunk = session.append('assistant/chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'old lifecycle' },
- })
+ const oldChunk = { type: 'text-delta', index: 0, text: 'old lifecycle' } as const
ctx.emit('agent/assistant-stream', {
agent,
frame: {
type: 'chunk', attemptId, revision: 2, index: 0,
- chunk: oldChunk.data.chunk, legacyChunkSeq: oldChunk.seq,
+ time: 101, chunk: oldChunk,
},
})
ctx.emit('agent/assistant-stream', {
@@ -606,7 +579,9 @@ describe('Session history raw journal', () => {
type: 'snapshot',
assistantStream: {
revision: 1,
- attempts: [{ attemptId, startedTime: 200, turn: 2, step: 1, chunks: [] }],
+ activeAttempt: {
+ attemptId, startedTime: 200, turn: 2, step: 1, nextIndex: 0, stream: [],
+ },
},
},
})
@@ -649,8 +624,7 @@ describe('Session history raw journal', () => {
ctx.emit('agent/assistant-stream', {
agent,
frame: {
- type: 'end', attemptId, revision: 2, index: 0,
- outcome: 'aborted', legacyChunkSeqs: [],
+ type: 'end', attemptId, revision: 2, index: 0, outcome: { kind: 'abandoned' },
},
})
const next = session.append('turn/end', {
@@ -821,25 +795,23 @@ describe('Session history raw journal', () => {
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
})
- it('paginates a message with many provenance sources without variadic argument expansion', async () => {
+ it('paginates a message with a large embedded stream without expanding physical records', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
- const sources = Array.from({ length: 128 }, () => session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'x' },
- }).seq)
+ session.append('step/start', { turn: 1, step: 1 })
+ const texts = Array.from({ length: 128 }, () => 'x')
const message = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
- content: [{ type: 'text', text: 'x'.repeat(sources.length) }],
+ content: [{ type: 'text', text: 'x'.repeat(texts.length) }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
- }, { surfaceOp: 'append', sourceEventSeqs: sources })
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: texts.slice(1).map(() => 0), texts }],
+ }, { surfaceOp: 'append' })
const scalarMin = Math.min
const min = vi.spyOn(Math, 'min').mockImplementation((...values) => {
@@ -853,68 +825,55 @@ describe('Session history raw journal', () => {
maxMessages: 1,
})
if (!response.ok) throw new Error('unreachable')
- expect(pageEvents(response.value).map(event => event.seq)).toEqual([...sources, message.seq])
- expect(response.value.records.filter(record => record.type === 'chunks')).toHaveLength(1)
+ expect(pageEvents(response.value).map(event => event.seq)).toEqual([message.seq])
+ expect(response.value.records).toEqual([{ type: 'event', event: message }])
expect(response.value.hasMore).toBe(true)
} finally {
min.mockRestore()
}
})
- it('encodes reasoning and tool-call runs as aligned chunk events', async () => {
+ it('keeps an earlier declared source on the same message-aligned page', async () => {
+ const { ctx } = await harness()
+ const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
+ const source = session.append('request/context', { provider: 'p', model: 'm' })
+ const laterSource = session.append('request/context', { provider: 'p', model: 'm' })
+ const message = session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'with source' }], source: { kind: 'user' },
+ }), { sourceEventSeqs: [source.seq, laterSource.seq], surfaceOp: 'append' })
+
+ const response = await remote.page({
+ address: { kind: 'session', sessionId: session.id },
+ throughSeq: message.seq,
+ maxMessages: 1,
+ })
+ if (!response.ok) throw new Error('unreachable')
+ expect(pageEvents(response.value).map(event => event.seq)).toEqual([source.seq, laterSource.seq, message.seq])
+ expect(response.value.hasMore).toBe(false)
+ await ctx.fiber.dispose()
+ })
+
+ it('keeps compact reasoning and tool-call runs nested in one attempt event', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
- const reasoning = [0, 1, 2].map(index => session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'reasoning-delta', index: 0, text: `r${String(index)}` },
- }))
const callId = ToolCallId('packed-call')
- const toolCall = [0, 1, 2].map(index => session.append('assistant/chunk', {
+ const attempt = session.append('assistant/attempt', {
turn: 1,
step: 1,
- chunk: { type: 'tool-call-delta', index: 1, id: callId, argumentsDelta: `a${String(index)}` },
- }))
+ stream: [
+ { type: 'reasoning-chunks', time0: 1, index: 0, dt: [1, 1], texts: ['r0', 'r1', 'r2'] },
+ { type: 'tool-call-chunks', time0: 4, index: 1, id: callId, dt: [1, 1], args: ['a0', 'a1', 'a2'] },
+ ],
+ })
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
})
if (!response.ok) throw new Error('unreachable')
- expect(response.value.records).toEqual([
- {
- type: 'chunks',
- event: {
- type: 'chunkrow/reasoning-chunks',
- seq: reasoning[0]?.seq,
- time: reasoning[0]?.time,
- data: {
- turn: 1,
- step: 1,
- index: 0,
- dt: reasoning.slice(1).map((event, index) => event.time - (reasoning[index]?.time ?? 0)),
- texts: ['r0', 'r1', 'r2'],
- },
- },
- },
- {
- type: 'chunks',
- event: {
- type: 'chunkrow/tool-call-chunks',
- seq: toolCall[0]?.seq,
- time: toolCall[0]?.time,
- data: {
- turn: 1,
- step: 1,
- index: 1,
- id: callId,
- dt: toolCall.slice(1).map((event, index) => event.time - (toolCall[index]?.time ?? 0)),
- args: ['a0', 'a1', 'a2'],
- },
- },
- },
- ])
+ expect(response.value.records).toEqual([{ type: 'event', event: attempt }])
await ctx.fiber.dispose()
})
diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts
index 6251952a6f..8ec2d199c1 100644
--- a/packages/api/session-controller/tests/session-projections.host.spec.ts
+++ b/packages/api/session-controller/tests/session-projections.host.spec.ts
@@ -154,14 +154,14 @@ describe('session.history projections block', () => {
const snapshot = await opening(remote(ctx), child.id)
expect(snapshot.header).toEqual({
- version: 1,
+ version: 2,
id: child.id,
createdAt: child.header.createdAt,
cwd: '/workspace',
parentSession: parent.id,
- seedLength: inheritedEventCount,
+ isSeeded: true,
})
- expect(snapshot.header).not.toHaveProperty('isSeeded')
+ expect(snapshot.header).not.toHaveProperty('seedLength')
})
it('tracks pending and used model selections across repeated request headers', async () => {
diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts
index a4cca8a210..72e5031e3f 100644
--- a/packages/api/session-controller/tests/session.client.spec.ts
+++ b/packages/api/session-controller/tests/session.client.spec.ts
@@ -99,29 +99,6 @@ describe('Session open', () => {
expect(api.followStarts).toHaveLength(2)
})
- it('lands a packed live record in openState=error instead of crashing the stream loop', async () => {
- const { api, session } = makeSession()
- api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b'))
- await session.open()
- expect(session.getSnapshot().openState).toBe('open')
-
- // The live tail may carry only events; a packed record breaks that contract.
- await api.pushFollow(SID, {
- type: 'chunks',
- event: {
- type: 'chunkrow/text-chunks',
- seq: 6,
- time: 6,
- data: { turn: 1, step: 1, index: 0, texts: ['a'], dt: [] },
- },
- } as never)
-
- await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
- expect(session.getSnapshot().openError).toMatchObject({
- code: 'gateway/internal', message: 'session live stream emitted a packed history record',
- })
- })
-
it('lands a Gateway-marked stream failure in openState=error', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.reject(new Error('socket died'))
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index 7214b33361..1ac43f176c 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -134,7 +134,7 @@ describe('search', () => {
})
describe('scope tree', () => {
- it('publishes live assistant chunks and durable settlement atomically through one event source', async () => {
+ it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
@@ -144,17 +144,10 @@ describe('scope tree', () => {
expect(binding.session.getSnapshot().openState).toBe('open')
})
const attemptId = LlmAttemptId('web-live-attempt')
- const durableChunk = {
- type: 'event' as const,
- event: {
- type: 'assistant/chunk', seq: 0, time: 1,
- data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live' } },
- },
- }
const durableMessage = {
type: 'event' as const,
event: {
- type: 'assistant/message', seq: 1, time: 2,
+ type: 'assistant/message', seq: 0, time: 2,
data: {
turn: 1,
step: 1,
@@ -164,8 +157,8 @@ describe('scope tree', () => {
source: { kind: 'model', provider: 'p', model: 'm' },
id: 'message-1',
},
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['live'] }],
},
- sourceEventSeqs: [0],
surfaceOp: 'append' as const,
},
}
@@ -181,16 +174,12 @@ describe('scope tree', () => {
turn: 1, step: 1,
},
})
- await b.api.pushFollow(sid('s1'), durableChunk)
- await Promise.resolve()
- expect(binding.eventSource.getSnapshot().entries).toEqual([])
-
await b.api.pushFollow(sid('s1'), {
type: 'assistant-stream',
frame: {
type: 'chunk', attemptId, revision: 2, index: 0,
- chunk: durableChunk.event.data.chunk,
- legacyChunkSeq: 0,
+ time: 1,
+ chunk: { type: 'text-delta', index: 0, text: 'live' },
},
})
await vi.waitFor(() => {
@@ -204,8 +193,7 @@ describe('scope tree', () => {
type: 'assistant-stream',
frame: {
type: 'end', attemptId, revision: 3, index: 1,
- outcome: 'committed',
- legacyChunkSeqs: [0],
+ outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
},
})
await vi.waitFor(() => {
@@ -213,8 +201,8 @@ describe('scope tree', () => {
})
expect(publications).toEqual([
- ['assistant/chunk'],
- ['assistant/chunk', 'assistant/message'],
+ ['assistant/live-chunk'],
+ ['assistant/live-chunk', 'assistant/message'],
])
dispose()
})
@@ -222,28 +210,15 @@ describe('scope tree', () => {
it('replaces an active assistant baseline on reconnect without duplicate chunks', async () => {
const b = bench()
const attemptId = LlmAttemptId('reconnect-attempt')
- const first = {
- type: 'event' as const,
- event: {
- type: 'assistant/chunk', seq: 0, time: 1,
- data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } },
- },
- }
- const second = {
- type: 'event' as const,
- event: {
- type: 'assistant/chunk', seq: 1, time: 2,
- data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } },
- },
- }
- let records = [first] as never[]
+ let records: never[] = []
b.api.onHistory = () => Promise.resolve(ok({ records, hasMore: false }))
b.api.assistantStreamBaseline = {
revision: 2,
- attempts: [{
+ activeAttempt: {
attemptId, startedTime: 1, turn: 1, step: 1,
- chunks: [first.event.data.chunk], legacyChunkSeqs: [0],
- }],
+ nextIndex: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }],
+ },
}
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
@@ -253,14 +228,14 @@ describe('scope tree', () => {
expect(binding.eventSource.getSnapshot().entries).toHaveLength(1)
})
- records = [first, second] as never[]
+ records = []
b.api.assistantStreamBaseline = {
revision: 3,
- attempts: [{
+ activeAttempt: {
attemptId, startedTime: 1, turn: 1, step: 1,
- chunks: [first.event.data.chunk, second.event.data.chunk],
- legacyChunkSeqs: [0, 1],
- }],
+ nextIndex: 2,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a', 'b'] }],
+ },
}
b.api.failStreams(new RemoteStreamCarrierError('lost'))
await vi.waitFor(() => {
@@ -268,57 +243,20 @@ describe('scope tree', () => {
expect(binding.eventSource.getSnapshot().entries).toHaveLength(2)
})
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1])
- })
-
- it('publishes a baseline-framed chunk when its durable event follows the opening cut', async () => {
- const b = bench()
- const attemptId = LlmAttemptId('opening-cut-attempt')
- const chunk = {
- type: 'event' as const,
- event: {
- type: 'assistant/chunk', seq: 0, time: 1,
- data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'late durable' } },
- },
- }
- b.api.onHistory = () => Promise.resolve(ok({ records: [], hasMore: false }))
- b.api.followCursor = -1
- b.api.assistantStreamBaseline = {
- revision: 2,
- attempts: [{
- attemptId, startedTime: 1, turn: 1, step: 1,
- chunks: [chunk.event.data.chunk], legacyChunkSeqs: [0],
- }],
- }
- await feedList(b, [{ id: 's1' }])
- b.svc.open(sid('s1'))
- const binding = b.svc.binding(sid('s1'))
- if (binding === undefined) throw new Error('expected Session binding')
- await vi.waitFor(() => {
- expect(binding.session.getSnapshot().openState).toBe('open')
- })
-
- await b.api.pushFollow(sid('s1'), chunk)
- await vi.waitFor(() => {
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
- })
- expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1)
+ expect(binding.eventSource.getSnapshot().entries.map(entry => (
+ entry.event.type === 'assistant/live-chunk' && entry.event.data.chunk.type === 'text-delta'
+ ? entry.event.data.chunk.text
+ : undefined
+ ))).toEqual(['a', 'b'])
})
it('stages a reconnect-tail assistant settlement behind its exact active attempt', async () => {
const b = bench()
const attemptId = LlmAttemptId('reconnect-settlement-attempt')
- const priorChunk = {
- type: 'event' as const,
- event: {
- type: 'assistant/chunk', seq: 0, time: 10,
- data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'retry ' } },
- },
- }
const priorMessage = {
type: 'event' as const,
event: {
- type: 'assistant/message', seq: 1, time: 11,
+ type: 'assistant/message', seq: 0, time: 11,
data: {
turn: 1,
step: 1,
@@ -328,22 +266,15 @@ describe('scope tree', () => {
source: { kind: 'model', provider: 'p', model: 'm' },
id: 'prior-attempt-message',
},
+ stream: [{ type: 'text-chunks', time0: 10, index: 0, dt: [], texts: ['retry '] }],
},
- sourceEventSeqs: [0],
surfaceOp: 'append' as const,
},
}
- const currentChunk = {
- type: 'event' as const,
- event: {
- type: 'assistant/chunk', seq: 2, time: 20,
- data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'settled' } },
- },
- }
const currentMessage = {
type: 'event' as const,
event: {
- type: 'assistant/message', seq: 3, time: 21,
+ type: 'assistant/message', seq: 1, time: 21,
data: {
turn: 1,
step: 1,
@@ -353,25 +284,25 @@ describe('scope tree', () => {
source: { kind: 'model', provider: 'p', model: 'm' },
id: 'current-attempt-message',
},
+ stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [], texts: ['settled'] }],
},
- sourceEventSeqs: [2],
surfaceOp: 'append' as const,
},
}
b.api.onHistory = () => Promise.resolve(ok({
- records: [priorChunk, priorMessage, currentChunk] as never[],
+ records: [priorMessage, currentMessage] as never[],
hasMore: false,
}))
b.api.assistantStreamBaseline = {
revision: 2,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 20,
turn: 1,
step: 1,
- chunks: [currentChunk.event.data.chunk],
- legacyChunkSeqs: [2],
- }],
+ nextIndex: 1,
+ stream: currentMessage.event.data.stream,
+ },
}
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
@@ -381,39 +312,29 @@ describe('scope tree', () => {
expect(binding.session.getSnapshot().openState).toBe('open')
})
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2])
- expect(binding.eventSource.getSnapshot().entries.at(1)?.event).toBe(priorMessage.event)
- expect(binding.eventSource.getSnapshot().entries.at(-1)?.event).toBe(currentChunk.event)
-
- await b.api.pushFollow(sid('s1'), currentMessage)
- await Promise.resolve()
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2])
+ expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
+ .toEqual(['assistant/message', 'assistant/live-chunk'])
+ expect(binding.eventSource.getSnapshot().entries[0]?.event).toBe(priorMessage.event)
await b.api.pushFollow(sid('s1'), {
type: 'assistant-stream',
frame: {
type: 'end', attemptId, revision: 3, index: 1,
- outcome: 'committed', legacyChunkSeqs: [2],
+ outcome: { kind: 'committed', eventType: 'assistant/message', seq: 1 },
},
})
await vi.waitFor(() => {
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1, 2, 3])
+ expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
+ .toEqual(['assistant/message', 'assistant/live-chunk', 'assistant/message'])
})
expect(binding.eventSource.getSnapshot().change).toEqual({
kind: 'append', entries: [currentMessage],
})
})
- it('replaces an invalid settlement with the authoritative post-end baseline', async () => {
+ it('rebaselines a reconnect settlement whose end index skips the active tail', async () => {
const b = bench()
const attemptId = LlmAttemptId('reconnect-end-index-attempt')
- const chunk = {
- type: 'event' as const,
- event: {
- type: 'assistant/chunk', seq: 0, time: 20,
- data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'settled' } },
- },
- }
const message = {
type: 'event' as const,
event: {
@@ -427,51 +348,50 @@ describe('scope tree', () => {
source: { kind: 'model', provider: 'p', model: 'm' },
id: 'current-attempt-message',
},
+ stream: [{ type: 'text-chunks', time0: 20, index: 0, dt: [], texts: ['settled'] }],
},
- sourceEventSeqs: [0],
surfaceOp: 'append' as const,
},
}
- let records = [chunk] as never[]
b.api.onHistory = () => Promise.resolve(ok({
- records,
+ records: [message] as never[],
hasMore: false,
}))
b.api.assistantStreamBaseline = {
revision: 2,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 20,
turn: 1,
step: 1,
- chunks: [chunk.event.data.chunk],
- legacyChunkSeqs: [0],
- }],
+ nextIndex: 1,
+ stream: message.event.data.stream,
+ },
}
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const binding = b.svc.binding(sid('s1'))
if (binding === undefined) throw new Error('expected Session binding')
await vi.waitFor(() => {
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
+ expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
+ .toEqual(['assistant/live-chunk'])
})
+ const openingRevision = binding.eventSource.getSnapshot().revision
- await b.api.pushFollow(sid('s1'), message)
- await Promise.resolve()
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0])
- records = [chunk, message] as never[]
- b.api.assistantStreamBaseline = { revision: 3, attempts: [] }
await b.api.pushFollow(sid('s1'), {
type: 'assistant-stream',
frame: {
type: 'end', attemptId, revision: 3, index: 0,
- outcome: 'committed', legacyChunkSeqs: [0],
+ outcome: { kind: 'committed', eventType: 'assistant/message', seq: 0 },
},
})
await vi.waitFor(() => {
expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(2)
- expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.seq)).toEqual([0, 1])
+ expect(b.api.activeFollows(sid('s1'))).toBe(1)
+ expect(binding.eventSource.getSnapshot().revision).toBeGreaterThan(openingRevision)
})
+ expect(binding.eventSource.getSnapshot().entries.map(entry => entry.event.type))
+ .toEqual(['assistant/live-chunk'])
})
it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => {
@@ -610,17 +530,18 @@ describe('Agent scope disposal lifecycle', () => {
value: {
type: 'snapshot',
header: {
- version: 1,
+ version: 2,
id: request.address.kind === 'session'
? request.address.sessionId
: request.address.childSessionId,
createdAt: 0,
+ isSeeded: false,
},
cursor: -1,
records: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
- assistantStream: { revision: 0, attempts: [] },
+ assistantStream: { revision: 0 },
} as const,
})
}
@@ -685,12 +606,12 @@ describe('Agent scope disposal lifecycle', () => {
done: false,
value: {
type: 'snapshot',
- header: { version: 1, id: sessionId, createdAt: 0 },
+ header: { version: 2, id: sessionId, createdAt: 0, isSeeded: false },
cursor: -1,
records: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
- assistantStream: { revision: 0, attempts: [] },
+ assistantStream: { revision: 0 },
} as const,
})
}
diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts
index 47b15cb705..48f58eef6f 100644
--- a/packages/api/session-controller/tests/transport.client.spec.ts
+++ b/packages/api/session-controller/tests/transport.client.spec.ts
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import {
- isRemoteFailure,
RemoteStream,
RemoteStreamCarrierError,
type RemoteStreamOptions,
@@ -42,18 +41,6 @@ function entry(seq: number): SessionEventEntry {
return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
}
-function chunks(seq0: number): SessionHistoryRecord {
- return {
- type: 'chunks',
- event: {
- type: 'chunkrow/text-chunks',
- seq: seq0,
- time: seq0,
- data: { turn: 1, step: 1, index: 0, texts: ['a', 'b', 'c'], dt: [1, 1] },
- },
- }
-}
-
function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage {
return { records, hasMore }
}
@@ -62,14 +49,15 @@ function snapshot(
cursor: number,
records: readonly SessionHistoryRecord[],
hasMore = false,
- assistantStream: SessionAssistantStreamBaseline = { revision: 0, attempts: [] },
+ assistantStream: SessionAssistantStreamBaseline = { revision: 0 },
): SessionFollowFrame {
return {
type: 'snapshot',
header: {
- version: 1,
+ version: 2,
id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId,
createdAt: 0,
+ isSeeded: false,
},
cursor,
records,
@@ -154,18 +142,18 @@ describe('Session Client stream adapters', () => {
const attemptId = LlmAttemptId('transport-attempt')
const baseline: SessionAssistantStreamBaseline = {
revision: 2,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 1,
turn: 1,
step: 1,
- chunks: [{ type: 'text-delta', index: 0, text: 'a' }],
- legacyChunkSeqs: [0],
- }],
+ nextIndex: 1,
+ stream: [{ type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['a'] }],
+ },
}
const frame: SessionAssistantStreamFrame = {
type: 'chunk', attemptId, revision: 3, index: 1,
- chunk: { type: 'text-delta', index: 0, text: 'b' }, legacyChunkSeq: 1,
+ time: 1, chunk: { type: 'text-delta', index: 0, text: 'b' },
}
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(0, [entry(0)], false, baseline), assistantFrame(frame)], hold: true }],
@@ -193,9 +181,10 @@ describe('Session Client stream adapters', () => {
frames: [{
type: 'snapshot',
header: {
- version: 1,
+ version: 2,
id: ADDRESS.sessionId,
createdAt: 0,
+ isSeeded: false,
},
cursor: -1,
records: [],
@@ -249,19 +238,18 @@ describe('Session Client stream adapters', () => {
}
const gap: SessionAssistantStreamFrame = {
type: 'chunk', attemptId, revision: 3, index: 0,
- chunk: { type: 'text-delta', index: 0, text: 'lost predecessor' },
- legacyChunkSeq: 1,
+ time: 1, chunk: { type: 'text-delta', index: 0, text: 'lost predecessor' },
}
const replacement: SessionAssistantStreamBaseline = {
revision: 3,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 1,
turn: 1,
step: 1,
- chunks: [gap.chunk],
- legacyChunkSeqs: [1],
- }],
+ nextIndex: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['lost predecessor'] }],
+ },
}
const remote = new ScriptedSessionRemote([
{
@@ -297,14 +285,14 @@ describe('Session Client stream adapters', () => {
const attemptId = LlmAttemptId('replacement-lifecycle-attempt')
const previous: SessionAssistantStreamBaseline = {
revision: 2,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 1,
turn: 1,
step: 1,
- chunks: [{ type: 'text-delta', index: 0, text: 'old' }],
- legacyChunkSeqs: [0],
- }],
+ nextIndex: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['old'] }],
+ },
}
const replacementStart: SessionAssistantStreamFrame = {
type: 'start', attemptId, revision: 1, startedTime: 2,
@@ -312,14 +300,14 @@ describe('Session Client stream adapters', () => {
}
const replacement: SessionAssistantStreamBaseline = {
revision: 1,
- attempts: [{
+ activeAttempt: {
attemptId,
startedTime: 2,
turn: 2,
step: 1,
- chunks: [],
- legacyChunkSeqs: [],
- }],
+ nextIndex: 0,
+ stream: [],
+ },
}
const remote = new ScriptedSessionRemote([
{
@@ -350,10 +338,9 @@ describe('Session Client stream adapters', () => {
}
})
- it('validates a packed logical range before publishing one compact Client entry', async () => {
- const row = chunks(1)
+ it('validates one scalar current-event range before publishing Client entries', async () => {
const remote = new ScriptedSessionRemote(
- [{ frames: [snapshot(4, [entry(0), row, entry(4)]), entry(5)], hold: true }],
+ [{ frames: [snapshot(2, [entry(0), entry(1), entry(2)]), entry(3)], hold: true }],
[],
)
const changes: SessionJournalChange[] = []
@@ -369,34 +356,11 @@ describe('Session Client stream adapters', () => {
type: 'replace',
entries: [
entry(0),
- row,
- entry(4),
+ entry(1),
+ entry(2),
],
})
- expect(changes[0]?.type === 'replace' ? changes[0].entries[1] : undefined).toBe(row)
- expect(changes[1]).toEqual({ type: 'append', entry: entry(5) })
- await stream.dispose()
- })
-
- it('rejects a packed record emitted by the live follow path', async () => {
- const failed = vi.fn()
- const remote = new ScriptedSessionRemote(
- [{ frames: [snapshot(-1, []), chunks(0) as SessionFollowFrame], hold: true }],
- [],
- )
- const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
- publish: vi.fn(),
- failed,
- })
-
- await stream.open({})
- await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() })
- const violation: unknown = failed.mock.calls[0]?.[0]
- expect(isRemoteFailure(violation)).toBe(true)
- expect(violation).toMatchObject({
- code: 'gateway/internal',
- message: 'session live stream emitted a packed history record',
- })
+ expect(changes[1]).toEqual({ type: 'append', entry: entry(3) })
await stream.dispose()
})
diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts
index 152576c67d..50a5c664be 100644
--- a/packages/bundle/headless/src/index.ts
+++ b/packages/bundle/headless/src/index.ts
@@ -90,8 +90,8 @@ function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome {
/**
* Project provider-reported reasoning from one owned run to stderr as it is
- * appended, while keeping final outcome derivation on the durable log.
- * @param ctx - plugin context carrying the Session event feed.
+ * streamed, while keeping final outcome derivation on the durable log.
+ * @param ctx - plugin context carrying the live Assistant frame feed.
* @param agent - the exact Agent whose reasoning belongs to this invocation.
* @param stderr - progress output sink.
* @returns a disposer that also terminates an unterminated reasoning line.
@@ -101,7 +101,6 @@ function streamReasoning(
agent: Agent,
stderr: HeadlessIo['stderr'],
): () => void {
- let started = false
let open = false
let endsWithNewline = true
const close = (): void => {
@@ -110,15 +109,17 @@ function streamReasoning(
open = false
endsWithNewline = true
}
- const dispose = ctx.on('session/event', (session, event) => {
- if (session !== agent.session) return
- if (event.type === 'turn/start') {
+ const dispose = ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => {
+ if (subject !== agent) return
+ if (frame.type === 'start') {
close()
- started = true
return
}
- if (!started || event.type !== 'assistant/chunk') return
- const chunk = event.data.chunk
+ if (frame.type === 'end') {
+ close()
+ return
+ }
+ const chunk = frame.chunk
switch (chunk.type) {
case 'reasoning-delta':
if (chunk.text === '') return
diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts
index bc788682bf..93dbb4b68c 100644
--- a/packages/bundle/headless/tests/headless.spec.ts
+++ b/packages/bundle/headless/tests/headless.spec.ts
@@ -3,9 +3,9 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentHandle, AssistantStreamFrame, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
-import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
+import { LlmAttemptId, createAssistantMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { apply, Config, internals } from '../src/index.ts'
@@ -15,7 +15,31 @@ afterEach(() => { Object.assign(internals, originalInternals) })
interface Script {
before?(session: Session): void
- afterPrompt(session: Session, message: UserMessage): Promise | void
+ afterPrompt(session: Session, message: UserMessage, agent: Agent): Promise | void
+}
+
+const frameStates = new WeakMap; revision: number; index: number }>()
+
+function startFrames(agent: Agent, turn = 1, step = 1): void {
+ const state = { attemptId: LlmAttemptId(`${agent.id}:test`), revision: 1, index: 0 }
+ frameStates.set(agent, state)
+ agent.ctx.emit('agent/assistant-stream', {
+ agent,
+ frame: {
+ type: 'start', attemptId: state.attemptId, revision: state.revision,
+ startedTime: Date.now(), turn, step,
+ },
+ })
+}
+
+function emitChunk(agent: Agent, chunk: StreamChunk): void {
+ const state = frameStates.get(agent)
+ if (state === undefined) throw new Error('test Assistant frames have not started')
+ const frame: AssistantStreamFrame = {
+ type: 'chunk', attemptId: state.attemptId, revision: ++state.revision,
+ index: state.index++, time: Date.now(), chunk,
+ }
+ agent.ctx.emit('agent/assistant-stream', { agent, frame })
}
function appendTurn(
@@ -30,6 +54,7 @@ function appendTurn(
session.append('user/message', message, { surfaceOp: 'append' })
if (text !== undefined) {
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createAssistantMessage({
@@ -80,7 +105,7 @@ async function bench(script: Script): Promise<{
send: () => {},
followup: (message: UserMessage) => {
agent.inbox.append('next-turn', message)
- idle = Promise.resolve().then(() => script.afterPrompt(session, message))
+ idle = Promise.resolve().then(() => script.afterPrompt(session, message, agent))
},
steer: () => {},
inject: () => {},
@@ -149,68 +174,26 @@ describe('headless runner', () => {
const reasoningAppended = Promise.withResolvers()
const release = Promise.withResolvers()
const test = await bench({
- async afterPrompt(session, message) {
+ async afterPrompt(session, message, agent) {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'reasoning-delta', index: 0, text: '' },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'reasoning-delta', index: 0, text: 'checking the workspace' },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'reasoning-delta', index: 0, text: ' safely\n' },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the workspace safely\n' } },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 2 } },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'block-start', index: 1, blockType: 'reasoning' },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'reasoning-delta', index: 1, text: 'second pass\n' },
- })
+ startFrames(agent)
+ emitChunk(agent, { type: 'block-start', index: 0, blockType: 'reasoning' })
+ emitChunk(agent, { type: 'reasoning-delta', index: 0, text: '' })
+ emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'checking the workspace' })
+ emitChunk(agent, { type: 'reasoning-delta', index: 0, text: ' safely\n' })
+ emitChunk(agent, { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the workspace safely\n' } })
+ emitChunk(agent, { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 2 } })
+ emitChunk(agent, { type: 'block-start', index: 1, blockType: 'reasoning' })
+ emitChunk(agent, { type: 'reasoning-delta', index: 1, text: 'second pass\n' })
reasoningAppended.resolve(undefined)
await release.promise
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'block-start', index: 2, blockType: 'text' },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'text-delta', index: 2, text: 'done' },
- })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'block-end', index: 2, block: { type: 'text', text: 'done' } },
- })
+ emitChunk(agent, { type: 'block-start', index: 2, blockType: 'text' })
+ emitChunk(agent, { type: 'text-delta', index: 2, text: 'done' })
+ emitChunk(agent, { type: 'block-end', index: 2, block: { type: 'text', text: 'done' } })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createAssistantMessage({
@@ -227,10 +210,12 @@ describe('headless runner', () => {
const other = test.ctx.sessions.create()
other.append('turn/start', { turn: 1 })
other.append('step/start', { turn: 1, step: 1 })
- other.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'reasoning-delta', index: 0, text: 'other session' },
+ test.ctx.emit('agent/assistant-stream', {
+ agent: { session: other } as Agent,
+ frame: {
+ type: 'chunk', attemptId: LlmAttemptId('other'), revision: 1,
+ index: 0, time: Date.now(), chunk: { type: 'reasoning-delta', index: 0, text: 'other session' },
+ },
})
const streamed = test.output()
release.resolve(undefined)
@@ -249,6 +234,50 @@ describe('headless runner', () => {
await test.ctx.fiber.dispose()
})
+ it('closes an unterminated reasoning line as soon as the attempt ends', async () => {
+ const reasoningAppended = Promise.withResolvers()
+ const releaseEnd = Promise.withResolvers()
+ const ended = Promise.withResolvers()
+ const finish = Promise.withResolvers()
+ const test = await bench({
+ async afterPrompt(session, message, agent) {
+ session.append('turn/start', { turn: 1 })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('user/message', message, { surfaceOp: 'append' })
+ startFrames(agent)
+ emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'unfinished reasoning' })
+ reasoningAppended.resolve(undefined)
+ await releaseEnd.promise
+ const state = frameStates.get(agent)
+ if (state === undefined) throw new Error('test Assistant frames have not started')
+ agent.ctx.emit('agent/assistant-stream', {
+ agent,
+ frame: {
+ type: 'end', attemptId: state.attemptId, revision: ++state.revision,
+ index: state.index, outcome: { kind: 'abandoned' },
+ },
+ })
+ ended.resolve(undefined)
+ await finish.promise
+ session.append('step/end', { turn: 1, step: 1 })
+ session.append('turn/end', {
+ turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
+ })
+ },
+ })
+ const running = test.run()
+ await reasoningAppended.promise
+ expect(test.output().err).toBe('dsh: reasoning:\nunfinished reasoning')
+
+ releaseEnd.resolve(undefined)
+ await ended.promise
+ expect(test.output().err).toBe('dsh: reasoning:\nunfinished reasoning\n')
+
+ finish.resolve(undefined)
+ await expect(running).resolves.toMatchObject({ code: 1 })
+ await test.ctx.fiber.dispose()
+ })
+
it('exits 1 when the final turn does not complete', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) },
@@ -280,15 +309,12 @@ describe('headless runner', () => {
it('separates an unterminated reasoning prefix from the terminal model failure', async () => {
const test = await bench({
- afterPrompt(session, message) {
+ afterPrompt(session, message, agent) {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
- session.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'reasoning-delta', index: 0, text: 'trying recovery' },
- })
+ startFrames(agent)
+ emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'trying recovery' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 166e10453c..bd95099f89 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -16,6 +16,12 @@ import type {
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
+import {
+ AssistantStreamAccumulator,
+ expandAssistantStream,
+ type AssistantStreamRecord,
+} from '@deepseek-ai/dsh-llm/assistant-stream'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
SessionEvent,
@@ -23,8 +29,6 @@ import type {
} from '@deepseek-ai/dsh-session/types'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
-import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows'
-import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
@@ -98,21 +102,7 @@ interface FixtureHistoryEntry {
readonly event: SessionEvent
}
-type FixtureChunkRowEvent = {
- [Kind in ChunkRow['type']]: {
- readonly type: `chunkrow/${Kind}`
- readonly seq: number
- readonly time: number
- readonly data: Extract['data']
- }
-}[ChunkRow['type']]
-
-interface FixtureHistoryChunkRun {
- readonly type: 'chunks'
- readonly event: FixtureChunkRowEvent
-}
-
-type FixtureHistoryRecord = FixtureHistoryEntry | FixtureHistoryChunkRun
+type FixtureHistoryRecord = FixtureHistoryEntry
type FixtureSessionAddress =
| { readonly kind: 'session'; readonly sessionId: SessionId }
@@ -125,8 +115,8 @@ type FixtureSessionAddress =
interface FixtureFollowRequest {
readonly address: FixtureSessionAddress
- readonly assistantStream?: true
readonly maxMessages?: number
+ readonly assistantStream?: true
}
interface FixturePageRequest {
@@ -142,7 +132,7 @@ interface FixtureSessionWireHeader {
readonly createdAt: number
readonly cwd?: string
readonly parentSession?: SessionId
- readonly seedLength?: number
+ readonly isSeeded: boolean
readonly origin?: 'subagent'
readonly delegationDepth?: number
readonly agentPreset?: string
@@ -156,14 +146,48 @@ type FixtureFollowFrame =
readonly records: readonly FixtureHistoryRecord[]
readonly hasMore: boolean
readonly projections: FixtureProjectionsBlock
- readonly assistantStream?: {
- readonly revision: 0
- readonly attempts: readonly []
- }
+ readonly assistantStream?: FixtureAssistantStreamBaseline
}
| FixtureHistoryEntry
+ | { readonly type: 'assistant-stream'; readonly frame: FixtureAssistantStreamFrame }
-type FixtureFollowEventFrame = Extract
+type FixtureFollowEventFrame = Exclude
+
+interface FixtureAssistantStreamBaseline {
+ readonly revision: number
+ readonly activeAttempt?: {
+ readonly attemptId: ReturnType
+ readonly turn: number
+ readonly step: number
+ readonly nextIndex: number
+ readonly stream: readonly AssistantStreamRecord[]
+ }
+}
+
+type FixtureAssistantStreamFrame =
+ | {
+ readonly type: 'start'
+ readonly attemptId: ReturnType
+ readonly revision: number
+ readonly turn: number
+ readonly step: number
+ }
+ | {
+ readonly type: 'chunk'
+ readonly attemptId: ReturnType
+ readonly revision: number
+ readonly index: number
+ readonly time: number
+ readonly chunk: StreamChunk
+ }
+ | {
+ readonly type: 'end'
+ readonly attemptId: ReturnType
+ readonly revision: number
+ readonly outcome:
+ | { readonly kind: 'committed'; readonly eventType: 'assistant/message' | 'assistant/attempt'; readonly seq: number }
+ | { readonly kind: 'abandoned' }
+ }
interface FixtureRemoteEventNotificationFrame {
readonly type: 'emit'
@@ -633,6 +657,26 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
}
}
+/** Build a lossless settled stream for static fixture messages. */
+function fixtureSettledStream(
+ message: AssistantMessage,
+ usage: TokenUsage,
+ time: number,
+): AssistantStreamRecord[] {
+ const stream: AssistantStreamRecord[] = []
+ for (const [index, block] of message.content.entries()) {
+ stream.push(
+ { type: 'chunk', time, chunk: { type: 'block-start', index, blockType: block.type } },
+ { type: 'chunk', time, chunk: { type: 'block-end', index, block } },
+ )
+ }
+ stream.push(
+ { type: 'chunk', time, chunk: { type: 'usage', usage } },
+ { type: 'chunk', time, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+ )
+ return stream
+}
+
/** fx-alpha history script: 75 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / context. */
function buildAlphaLog(): SessionEvent[] {
@@ -641,16 +685,22 @@ function buildAlphaLog(): SessionEvent[] {
const push = (e: Record): number => {
const seq = events.length
const data = e['data'] as Record | undefined
+ const nextTime = time + 800
const authored = e['type'] === 'assistant/message' && data !== undefined
? {
...e,
data: {
...data,
usage: fixtureUsage(data['turn'] as number, data['step'] as number),
+ stream: fixtureSettledStream(
+ data['message'] as AssistantMessage,
+ fixtureUsage(data['turn'] as number, data['step'] as number),
+ nextTime,
+ ),
},
}
: e
- events.push({ seq, time: (time += 800), ...authored })
+ events.push({ seq, time: (time = nextTime), ...authored })
return seq
}
// Completed fixture requests retain the route capacity recorded with them.
@@ -1041,23 +1091,14 @@ interface FixtureUsageSample {
/** Read one provider usage sample from either durable carrier. */
function usageSampleOf(event: SessionEvent): FixtureUsageSample | undefined {
- const item = event as unknown as {
- type: string
- data: {
- turn?: number
- step?: number
- usage?: TokenUsage
- chunk?: { type?: string; usage?: TokenUsage }
- }
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
+ let usage = event.type === 'assistant/message' ? event.data.usage : undefined
+ for (const member of expandAssistantStream(event.data.stream)) {
+ if (member.chunk.type === 'usage') usage = member.chunk.usage
}
- const usage = item.type === 'assistant/chunk' && item.data.chunk?.type === 'usage'
- ? item.data.chunk.usage
- : item.type === 'assistant/message'
- ? item.data.usage
- : undefined
- return usage === undefined || item.data.turn === undefined || item.data.step === undefined
+ return usage === undefined
? undefined
- : { turn: item.data.turn, step: item.data.step, usage }
+ : { turn: event.data.turn, step: event.data.step, usage }
}
/** Fixture parallel of token-meter's last-sample-replacing usage projection. */
@@ -1114,14 +1155,18 @@ function sessionStatsOf(log: readonly SessionEvent[]): {
case 'step/start':
openStep = { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null }
break
- case 'assistant/chunk':
- if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step
- && openStep.firstTokenTime === null && isFixtureTokenDelta(event.data.chunk)) {
- openStep.firstTokenTime = event.time
- }
+ case 'assistant/attempt': {
+ if (openStep === null || openStep.turn !== event.data.turn || openStep.step !== event.data.step) break
+ const first = expandAssistantStream(event.data.stream)
+ .find(member => isFixtureTokenDelta(member.chunk))?.time
+ if (openStep.firstTokenTime === null && first !== undefined) openStep.firstTokenTime = first
break
+ }
case 'assistant/message': {
if (openStep === null || openStep.turn !== event.data.turn || openStep.step !== event.data.step) break
+ const first = expandAssistantStream(event.data.stream)
+ .find(member => isFixtureTokenDelta(member.chunk))?.time
+ if (openStep.firstTokenTime === null && first !== undefined) openStep.firstTokenTime = first
value.llmMs += Math.max(0, event.time - openStep.startTime)
if (openStep.firstTokenTime !== null) {
value.ttftMs += Math.max(0, openStep.firstTokenTime - openStep.startTime)
@@ -1457,26 +1502,7 @@ function pageOf(
break
}
}
- const records = packChunkRuns(log.slice(start, end)).map((record): FixtureHistoryRecord => {
- if (!isChunkRow(record)) return { type: 'event', event: record }
- switch (record.type) {
- case 'text-chunks':
- return {
- type: 'chunks',
- event: { type: 'chunkrow/text-chunks', seq: record.seq0, time: record.time0, data: record.data },
- }
- case 'reasoning-chunks':
- return {
- type: 'chunks',
- event: { type: 'chunkrow/reasoning-chunks', seq: record.seq0, time: record.time0, data: record.data },
- }
- case 'tool-call-chunks':
- return {
- type: 'chunks',
- event: { type: 'chunkrow/tool-call-chunks', seq: record.seq0, time: record.time0, data: record.data },
- }
- }
- })
+ const records = log.slice(start, end).map((event): FixtureHistoryRecord => ({ type: 'event', event }))
return { records, hasMore: start > 0 }
}
@@ -2012,6 +2038,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const controlConns = new Set>()
const followConns = new Map>>()
+ interface FixtureAttemptState {
+ readonly attemptId: ReturnType
+ readonly turn: number
+ readonly step: number
+ readonly stream: AssistantStreamAccumulator
+ index: number
+ }
+ const activeAttempts = new Map()
+ const assistantRevisions = new Map()
const workspaceConns = new Set>()
const remoteEventConns = new Map>()
const emitControl = (frame: FixtureControlFrame): void => {
@@ -2029,6 +2064,45 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const emitFollow = (sessionId: SessionId, entry: FixtureHistoryEntry): void => {
for (const conn of followConns.get(sessionId) ?? []) conn.push(entry)
}
+ const emitAssistant = (sessionId: SessionId, frame: FixtureAssistantStreamFrame): void => {
+ for (const conn of followConns.get(sessionId) ?? []) conn.push({ type: 'assistant-stream', frame })
+ }
+ const nextAssistantRevision = (sessionId: SessionId): number => {
+ const revision = (assistantRevisions.get(sessionId) ?? 0) + 1
+ assistantRevisions.set(sessionId, revision)
+ return revision
+ }
+ const beginAssistant = (sessionId: SessionId, turn: number, step: number): FixtureAttemptState => {
+ const attemptId = LlmAttemptId(`${sessionId}:fixture:${String(nextAssistantRevision(sessionId))}`)
+ const attempt = { attemptId, turn, step, stream: new AssistantStreamAccumulator(), index: 0 }
+ activeAttempts.set(sessionId, attempt)
+ emitAssistant(sessionId, {
+ type: 'start', attemptId, revision: assistantRevisions.get(sessionId) as number, turn, step,
+ })
+ return attempt
+ }
+ const pushAssistant = (sessionId: SessionId, chunk: StreamChunk): void => {
+ const attempt = activeAttempts.get(sessionId)
+ if (attempt === undefined) throw new Error(`fixture: no active Assistant attempt for ${sessionId}`)
+ const timed = attempt.stream.push({ time: Date.now(), chunk })
+ emitAssistant(sessionId, {
+ type: 'chunk', attemptId: attempt.attemptId, revision: nextAssistantRevision(sessionId),
+ index: attempt.index++, time: timed.time, chunk: timed.chunk,
+ })
+ }
+ const commitAssistant = (sessionId: SessionId, event: SessionEvent): void => {
+ const attempt = activeAttempts.get(sessionId)
+ if (attempt === undefined) throw new Error(`fixture: no active Assistant attempt for ${sessionId}`)
+ activeAttempts.delete(sessionId)
+ emitAssistant(sessionId, {
+ type: 'end', attemptId: attempt.attemptId, revision: nextAssistantRevision(sessionId),
+ outcome: {
+ kind: 'committed',
+ eventType: event.type === 'assistant/message' ? 'assistant/message' : 'assistant/attempt',
+ seq: event.seq,
+ },
+ })
+ }
function sessionOk(value: T): Promise> {
return Promise.resolve({ ok: true, value })
@@ -2063,7 +2137,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
}
return log
}
- const append = (id: SessionId, e: Record): void => {
+ const append = (id: SessionId, e: Record): SessionEvent => {
const log = logOf(id)
const event = { seq: SessionSeq(log.length), time: Date.now(), ...e } as unknown as SessionEvent
log.push(event)
@@ -2075,6 +2149,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
if (summary !== undefined) summary.updatedAt = event.time
emitRemote('api-session/activity', [id, event.time])
}
+ return event
}
/** Append one durable goal/change (host GoalService parallel). */
@@ -2575,10 +2650,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
data: userMessage(text(`Reasoning chunk stress: ${String(chunkCount)} chunks.`)),
})
append(sessionId, { type: 'step/start', data: { turn, step: 0 } })
- append(sessionId, {
- type: 'assistant/chunk',
- data: { turn, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } },
- })
+ beginAssistant(sessionId, turn, 0)
+ pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'reasoning' })
const startedAt = Date.now()
const pump = (): void => {
@@ -2589,10 +2662,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const chunkText = index === chunkCount - 1
? `\n${marker}`
: index % 64 === 63 ? '推理\n' : '推理'
- append(sessionId, {
- type: 'assistant/chunk',
- data: { turn, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: chunkText } },
- })
+ pushAssistant(sessionId, { type: 'reasoning-delta', index: 0, text: chunkText })
}
state.emitted = end
if (end < chunkCount) {
@@ -2618,8 +2688,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
append(sessionId, { type: 'turn/start', data: { turn } })
append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
- append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
- append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
+ beginAssistant(sessionId, turn, 1)
+ pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'text' })
+ pushAssistant(sessionId, { type: 'text-delta', index: 0, text: '应撤回的半截回复' })
},
/** Record one retry decision; the next attempt remains in the same step. */
scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
@@ -2627,11 +2698,18 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
if (!scenario.stepStarted) {
- append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
- append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
+ beginAssistant(sessionId, scenario.turn, 1)
+ pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'text' })
+ pushAssistant(sessionId, { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` })
scenario.stepStarted = true
}
const failure = { code: 'TRANSPORT', message: '连接被重置' }
+ pushAssistant(sessionId, { type: 'finish', reason: { kind: 'error', failure } })
+ const attempt = activeAttempts.get(sessionId) as FixtureAttemptState
+ const attemptEvent = append(sessionId, {
+ type: 'assistant/attempt', data: { turn: scenario.turn, step: 1, stream: attempt.stream.snapshot() },
+ })
+ commitAssistant(sessionId, attemptEvent)
append(sessionId, {
type: 'llm/retry',
data: {
@@ -2648,6 +2726,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
const failure = { code: 'TRANSPORT', message: '连接被重置' }
+ const active = activeAttempts.get(sessionId)
+ if (active !== undefined) {
+ pushAssistant(sessionId, { type: 'finish', reason: { kind: 'error', failure } })
+ const attemptEvent = append(sessionId, {
+ type: 'assistant/attempt', data: { turn: scenario.turn, step: 1, stream: active.stream.snapshot() },
+ })
+ commitAssistant(sessionId, attemptEvent)
+ }
append(sessionId, {
type: 'llm/retry',
data: {
@@ -2668,20 +2754,24 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const scenario = retryScenarios.get(sessionId)
if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
retryScenarios.delete(sessionId)
- append(sessionId, { type: 'assistant/chunk', data: {
- turn: scenario.turn,
- step: 1,
- chunk: { type: 'block-start', index: 0, blockType: 'text' },
- } })
- append(sessionId, {
+ const completed = '重试后的完整回复'
+ beginAssistant(sessionId, scenario.turn, 1)
+ pushAssistant(sessionId, { type: 'block-start', index: 0, blockType: 'text' })
+ pushAssistant(sessionId, { type: 'text-delta', index: 0, text: completed })
+ pushAssistant(sessionId, { type: 'block-end', index: 0, block: { type: 'text', text: completed } })
+ pushAssistant(sessionId, { type: 'finish', reason: { kind: 'stop' } })
+ const attempt = activeAttempts.get(sessionId) as FixtureAttemptState
+ const message = append(sessionId, {
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn: scenario.turn,
step: 1,
- message: assistantMessage(text('重试后的完整回复')),
+ message: assistantMessage(text(completed)),
+ stream: attempt.stream.snapshot(),
},
})
+ commitAssistant(sessionId, message)
append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
setRunning(sessionId, false)
@@ -2702,26 +2792,36 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const startReply = (id: SessionId, turn: number, replyText: string): void => {
const step = 0
append(id, { type: 'step/start', data: { turn, step } })
- append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
+ beginAssistant(id, turn, step)
+ pushAssistant(id, { type: 'block-start', index: 0, blockType: 'text' })
/* v8 ignore next -- the ?? arm needs a null match, but every fixture reply is non-empty. */
const pieces = replyText.match(/[\s\S]{1,6}/gu) ?? [replyText]
let i = 0
const finish = (aborted: boolean): void => {
replays.delete(id)
const done = pieces.slice(0, i).join('')
- append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
- append(id, {
+ pushAssistant(id, { type: 'block-end', index: 0, block: { type: 'text', text: done } })
+ pushAssistant(id, { type: 'usage', usage: fixtureUsage(turn, step) })
+ if (!aborted) pushAssistant(id, { type: 'finish', reason: { kind: 'stop' } })
+ const attempt = activeAttempts.get(id) as FixtureAttemptState
+ const message = append(id, {
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn,
step,
- message: assistantMessage(text(aborted ? `${done}(已中断)` : done)),
+ message: assistantMessage(text(done)),
+ stream: attempt.stream.snapshot(),
usage: fixtureUsage(turn, step),
+ ...(aborted ? { interrupted: true } : {}),
},
})
+ commitAssistant(id, message)
append(id, { type: 'step/end', data: { turn, step } })
- append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
+ append(id, { type: 'turn/end', data: {
+ turn,
+ reason: aborted ? { kind: 'aborted', reason: { kind: 'user' } } : { kind: 'completed' },
+ } })
setRunning(id, false)
}
const tick = (): void => {
@@ -2731,7 +2831,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
return
}
i++
- append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index: 0, text: piece } } })
+ pushAssistant(id, { type: 'text-delta', index: 0, text: piece })
replays.set(id, { timer: setTimeout(tick, 80), finish })
}
replays.set(id, { timer: setTimeout(tick, 80), finish })
@@ -3211,11 +3311,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
yield {
type: 'snapshot',
header: {
- version: 0,
+ version: 2,
id: sessionId,
createdAt: summary.updatedAt,
...(summary.cwd === undefined ? {} : { cwd: summary.cwd }),
...(summary.parentSessionId === undefined ? {} : { parentSession: summary.parentSessionId }),
+ isSeeded: summary.parentSessionId !== undefined,
...(summary.origin === undefined ? {} : { origin: summary.origin }),
...(summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }),
},
@@ -3223,13 +3324,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
records: initial.records,
hasMore: initial.hasMore,
projections: { asOfSeq: cursor, values: projectionValuesOf(snapshot) },
- // The fixture has no process-local frame producer, but an opted-in
- // consumer still requires a complete opening baseline.
- ...(request.assistantStream === true
- ? { assistantStream: { revision: 0, attempts: [] } }
- : {}),
+ ...(request.assistantStream === true ? {
+ assistantStream: {
+ revision: assistantRevisions.get(sessionId) ?? 0,
+ ...(activeAttempts.get(sessionId) === undefined
+ ? {}
+ : { activeAttempt: {
+ attemptId: (activeAttempts.get(sessionId) as FixtureAttemptState).attemptId,
+ turn: (activeAttempts.get(sessionId) as FixtureAttemptState).turn,
+ step: (activeAttempts.get(sessionId) as FixtureAttemptState).step,
+ nextIndex: (activeAttempts.get(sessionId) as FixtureAttemptState).index,
+ stream: (activeAttempts.get(sessionId) as FixtureAttemptState).stream.snapshot(),
+ } }),
+ },
+ } : {}),
}
for await (const frame of conn.drain(signal)) {
+ if (frame.type === 'assistant-stream') {
+ yield frame
+ continue
+ }
if (frame.event.seq < nextSeq) continue
if (frame.event.seq !== nextSeq) {
throw new Error(`fixture: session event stream skipped seq ${String(nextSeq)}`)
diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts
index 015e8887d4..3b35297d3c 100644
--- a/packages/client/connection/tests/fixture.client.spec.ts
+++ b/packages/client/connection/tests/fixture.client.spec.ts
@@ -5,11 +5,9 @@ import type {
RpcResult,
SessionEvent,
SessionId,
+ StreamChunk,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
-import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows'
-import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
-import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import {
createFixtureConnectionRpc,
createFixtureFaces,
@@ -43,21 +41,7 @@ interface FixtureHistoryEntry {
readonly event: SessionEvent
}
-type FixtureChunkRowEvent = {
- [Kind in ChunkRow['type']]: {
- readonly type: `chunkrow/${Kind}`
- readonly seq: number
- readonly time: number
- readonly data: Extract['data']
- }
-}[ChunkRow['type']]
-
-interface FixtureHistoryChunkRun {
- readonly type: 'chunks'
- readonly event: FixtureChunkRowEvent
-}
-
-type FixtureHistoryRecord = FixtureHistoryEntry | FixtureHistoryChunkRun
+type FixtureHistoryRecord = FixtureHistoryEntry
interface FixturePage {
readonly records: readonly FixtureHistoryRecord[]
@@ -65,21 +49,27 @@ interface FixturePage {
}
function historyEvents(records: readonly FixtureHistoryRecord[]): SessionEvent[] {
- return records.flatMap(record => record.type === 'event'
- ? [record.event]
- : decodeStorageRecord(chunkRow(record.event)))
+ return records.map(record => record.event)
}
-function chunkRow(event: FixtureChunkRowEvent): ChunkRow {
- switch (event.type) {
- case 'chunkrow/text-chunks':
- return { type: 'text-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
- case 'chunkrow/reasoning-chunks':
- return { type: 'reasoning-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
- case 'chunkrow/tool-call-chunks':
- return { type: 'tool-call-chunks', seq0: SessionSeq(event.seq), time0: event.time, data: event.data }
+type FixtureAssistantStreamFrame =
+ | { readonly type: 'start'; readonly attemptId: string; readonly revision: number; readonly turn: number; readonly step: number }
+ | {
+ readonly type: 'chunk'
+ readonly attemptId: string
+ readonly revision: number
+ readonly index: number
+ readonly time: number
+ readonly chunk: StreamChunk
+ }
+ | {
+ readonly type: 'end'
+ readonly attemptId: string
+ readonly revision: number
+ readonly outcome:
+ | { readonly kind: 'committed'; readonly eventType: 'assistant/message' | 'assistant/attempt'; readonly seq: number }
+ | { readonly kind: 'abandoned' }
}
-}
type FixtureFollowFrame =
| {
@@ -92,11 +82,19 @@ type FixtureFollowFrame =
readonly values: Readonly>
}
readonly assistantStream?: {
- readonly revision: 0
- readonly attempts: readonly []
+ readonly revision: number
+ readonly activeAttempt?: {
+ readonly attemptId: string
+ readonly startedTime: number
+ readonly turn: number
+ readonly step: number
+ readonly nextIndex: number
+ readonly stream: readonly unknown[]
+ }
}
}
| FixtureHistoryEntry
+ | { readonly type: 'assistant-stream'; readonly frame: FixtureAssistantStreamFrame }
type FixtureControlFrame =
| {
@@ -626,7 +624,7 @@ describe('createFixtureApi', () => {
try {
const opening = await iterator.next()
if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing')
- expect(opening.value.assistantStream).toEqual({ revision: 0, attempts: [] })
+ expect(opening.value.assistantStream).toEqual({ revision: 0 })
} finally {
abort.abort()
await iterator.return?.()
@@ -895,10 +893,14 @@ describe('createFixtureApi', () => {
await api.sessions.cancel(req({ sessionId: id }))
const frames = await followPromise
const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : [])
+ const assistantFrames = frames.flatMap(frame => frame.type === 'assistant-stream' ? [frame.frame] : [])
expect(types).toContain('turn/start')
expect(types).toContain('user/message')
- expect(types).toContain('assistant/chunk')
expect(types).toContain('assistant/message')
+ expect(assistantFrames.some(frame => frame.type === 'chunk')).toBe(true)
+ expect(assistantFrames.some(frame => frame.type === 'end'
+ && frame.outcome.kind === 'committed'
+ && frame.outcome.eventType === 'assistant/message')).toBe(true)
expect(types.at(-1)).toBe('turn/end')
// Capacity is durable log state, not a transient frame: the prompt path
// records request/context and the projection carries it to the client.
@@ -919,7 +921,9 @@ describe('createFixtureApi', () => {
&& (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
const finalize = frames.find(frame => frame.type === 'event' && frame.event.type === 'assistant/message')
if (finalize?.type !== 'event') throw new Error('assistant final event missing')
- expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
+ if (finalize.event.type !== 'assistant/message') throw new Error('assistant final event has wrong type')
+ expect(finalize.event.data.interrupted).toBe(true)
+ expect(finalize.event.data.stream.length).toBeGreaterThan(0)
controlAbort.abort()
await controlPromise
// Idle cancel: no replay in flight, must not explode; running flips false.
@@ -1588,10 +1592,10 @@ describe('createFixtureApi', () => {
const abort = new AbortController()
try {
const streamed = collectValues(api.sessionRemote.follow(sid('fx-alpha'), abort.signal), abort, frames => frames.some(frame => (
- frame.type === 'event'
- && frame.event.type === 'assistant/chunk'
- && frame.event.data.chunk.type === 'reasoning-delta'
- && frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE')
+ frame.type === 'assistant-stream'
+ && frame.frame.type === 'chunk'
+ && frame.frame.chunk.type === 'reasoning-delta'
+ && frame.frame.chunk.text.includes('REASONING_STRESS_COMPLETE')
)))
const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 16)).toThrow(/already running/)
@@ -1607,10 +1611,10 @@ describe('createFixtureApi', () => {
const frames = await streamed
const deltas = frames.flatMap(frame => (
- frame.type === 'event'
- && frame.event.type === 'assistant/chunk'
- && frame.event.data.chunk.type === 'reasoning-delta'
- ? [frame.event.data.chunk.text]
+ frame.type === 'assistant-stream'
+ && frame.frame.type === 'chunk'
+ && frame.frame.chunk.type === 'reasoning-delta'
+ ? [frame.frame.chunk.text]
: []
))
expect(deltas).toEqual(['推理', '推理', `\n${marker}`])
diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts
index d6d8bad550..bf92eabbda 100644
--- a/packages/client/tsdown.client.ts
+++ b/packages/client/tsdown.client.ts
@@ -58,7 +58,7 @@ function styleInjectionModule(
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
-export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$)/
+export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|timeout|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-agent-presets\/display$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
index a6a460f0ea..a135515803 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
@@ -1,8 +1,9 @@
import type { Context } from '@deepseek-ai/cordis'
-import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { AssistantChatData } from '../contract/chat-nodes.ts'
@@ -39,12 +40,6 @@ interface AssistantState {
readonly usage: unknown
}
-function isChunkRunEvent(event: ConversationMatch['event']): event is ChunkRowEvent {
- return event.type === 'chunkrow/text-chunks'
- || event.type === 'chunkrow/reasoning-chunks'
- || event.type === 'chunkrow/tool-call-chunks'
-}
-
function initialState(turn: number, step: number): AssistantState {
return {
turn,
@@ -95,9 +90,12 @@ function resetForRetry(state: AssistantState): AssistantState {
}
}
-function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState {
- if (match.event.type !== 'assistant/chunk') return state
- const chunk = match.event.data.chunk
+function updateChunk(
+ state: AssistantState,
+ chunk: StreamChunk,
+ seq: number,
+ time: number,
+): AssistantState {
const blocks = [...state.blocks]
let changedIndex = -1
let previousVisible = false
@@ -156,94 +154,23 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
visibleBlocks,
hidden: visibleBlocks > 0 ? false : state.hidden,
...visibleBlocks > 0 && state.firstVisibleSeq === undefined
- ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
+ ? { firstVisibleSeq: seq, firstVisibleTime: time }
: {},
...firstToken && state.firstTokenTime === undefined
- ? { firstTokenTime: match.event.time }
+ ? { firstTokenTime: time }
: {},
}
}
-interface ChunkRunBoundaries {
- readonly firstTokenTime: number | undefined
- readonly firstVisible: { readonly seq: number; readonly time: number } | undefined
-}
-
-function chunkRunBoundaries(
- event: ChunkRowEvent,
- needsToken: boolean,
- needsVisible: boolean,
- visibleFromStart: boolean,
-): ChunkRunBoundaries {
- const fragments = event.type === 'chunkrow/tool-call-chunks' ? event.data.args : event.data.texts
- const nameStartsToken = event.type === 'chunkrow/tool-call-chunks'
- && Object.hasOwn(event.data, 'name')
- let firstTokenTime: number | undefined
- let firstVisible: ChunkRunBoundaries['firstVisible']
- let time = event.time
- for (let index = 0; index < fragments.length; index++) {
- const fragment = fragments[index] as string
- if (needsToken && firstTokenTime === undefined && (nameStartsToken || fragment !== '')) {
- firstTokenTime = time
- }
- if (needsVisible && firstVisible === undefined
- && (visibleFromStart
- || (event.type !== 'chunkrow/tool-call-chunks' && fragment.trim() !== ''))) {
- firstVisible = { seq: event.seq + index, time }
- }
- if ((!needsToken || firstTokenTime !== undefined)
- && (!needsVisible || firstVisible !== undefined)) break
- time += event.data.dt[index] ?? 0
- }
- return { firstTokenTime, firstVisible }
-}
-
-function updateChunkRun(state: AssistantState, event: ChunkRowEvent): AssistantState {
- const blocks = [...state.blocks]
- const previous = blocks[event.data.index]
- const previousVisible = blockIsVisible(previous)
- let visibleFromStart = state.visibleBlocks - Number(previousVisible) > 0
- if (event.type === 'chunkrow/text-chunks') {
- const text = previous?.kind === 'text' ? previous.text : ''
- visibleFromStart ||= text.trim() !== ''
- blocks[event.data.index] = { kind: 'text', text: text + event.data.texts.join('') }
- } else if (event.type === 'chunkrow/reasoning-chunks') {
- const text = previous?.kind === 'reasoning' ? previous.text : ''
- visibleFromStart ||= text.trim() !== ''
- blocks[event.data.index] = { kind: 'reasoning', text: text + event.data.texts.join('') }
- } else {
- const base = previous?.kind === 'tool-call'
- ? previous
- : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
- blocks[event.data.index] = {
- kind: 'tool-call',
- callId: base.callId || String(event.data.id),
- name: Object.hasOwn(event.data, 'name') ? event.data.name as string : base.name,
- argsRaw: base.argsRaw + event.data.args.join(''),
- }
- }
- const boundaries = chunkRunBoundaries(
- event,
- state.firstTokenTime === undefined,
- state.firstVisibleSeq === undefined,
- visibleFromStart,
- )
- const visibleBlocks = state.visibleBlocks
- - Number(previousVisible)
- + Number(blockIsVisible(blocks[event.data.index]))
- return {
- ...state,
- blocks,
- visibleBlocks,
- hidden: visibleBlocks > 0 ? false : state.hidden,
- ...(boundaries.firstVisible === undefined ? {} : {
- firstVisibleSeq: boundaries.firstVisible.seq,
- firstVisibleTime: boundaries.firstVisible.time,
- }),
- ...(boundaries.firstTokenTime === undefined ? {} : {
- firstTokenTime: boundaries.firstTokenTime,
- }),
+function updateEmbedded(
+ state: AssistantState,
+ event: Extract,
+): AssistantState {
+ let next = state
+ for (const member of expandAssistantStream(event.data.stream)) {
+ next = updateChunk(next, member.chunk, event.seq, member.time)
}
+ return next
}
function closedBoundary(location: ConversationLocation): { seq: number; time: number } | undefined {
@@ -300,15 +227,14 @@ function finalNode(
function fallbackState(context: ConversationNodeContext): AssistantState | undefined {
let state: AssistantState | undefined
for (const match of context.matches) {
- if (isChunkRunEvent(match.event)) {
+ if (match.event.type === 'assistant/live-chunk') {
state ??= initialState(match.event.data.turn, match.event.data.step)
- state = updateChunkRun(state, match.event)
+ state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time)
continue
}
- if (match.event.type === 'assistant/chunk') {
+ if (match.event.type === 'assistant/message' || match.event.type === 'assistant/attempt') {
state ??= initialState(match.event.data.turn, match.event.data.step)
- state = updateChunk(state, match)
- continue
+ state = updateEmbedded(state, match.event)
}
if (match.event.type === 'assistant/message') {
state ??= initialState(match.event.data.turn, match.event.data.step)
@@ -370,13 +296,11 @@ export const assistantDefinition: ConversationNodeDefinition = {
target: 'chat',
match: (event) => {
if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
- if (event.type === 'assistant/chunk'
+ if (event.type === 'assistant/live-chunk'
+ || event.type === 'assistant/attempt'
|| (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
- if (isChunkRunEvent(event)) {
- return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
- }
if (event.type === 'llm/retry') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
@@ -387,14 +311,15 @@ export const assistantDefinition: ConversationNodeDefinition = {
return initialState(match.event.data.turn, match.event.data.step)
},
update: (context, match) => {
- if (isChunkRunEvent(match.event)) {
- return updateChunkRun(context.state, match.event)
+ if (match.event.type === 'assistant/live-chunk') {
+ return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
}
- if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
+ if (match.event.type === 'assistant/attempt') return updateEmbedded(context.state, match.event)
if (match.event.type === 'assistant/message') {
+ const streamed = updateEmbedded(context.state, match.event)
const blocks = toAssistantBlocks(match.event.data.message.content)
return {
- ...context.state,
+ ...streamed,
blocks,
visibleBlocks: countVisibleBlocks(blocks),
hidden: false,
@@ -409,8 +334,7 @@ export const assistantDefinition: ConversationNodeDefinition = {
},
publication: (match) => {
if (match.event.type === 'step/start') return 'none'
- if (isChunkRunEvent(match.event)) return 'animation-frame'
- if (match.event.type !== 'assistant/chunk') return 'immediate'
+ if (match.event.type !== 'assistant/live-chunk') return 'immediate'
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
},
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts
index 5c5a77c82c..adba1ef867 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts
@@ -15,12 +15,9 @@ declare module '../contract/chat-nodes.ts' {
export const unknownFallbackDefinition: ConversationNodeDefinition = {
kind: 'unknown-surface',
target: 'chat',
- match: (event) => {
- if (event.type === 'chunkrow/text-chunks'
- || event.type === 'chunkrow/reasoning-chunks'
- || event.type === 'chunkrow/tool-call-chunks') return null
- return isAppendSurfaceEvent(event) ? { id: String(event.seq), role: 'start' } : null
- },
+ match: event => event.type !== 'assistant/live-chunk' && isAppendSurfaceEvent(event)
+ ? { id: String(event.seq), role: 'start' }
+ : null,
start: (_context, match) => ({
kind: 'unknown',
seq: match.event.seq,
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts
index ea52a3a565..278927c6ae 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts
@@ -15,7 +15,7 @@ export function isVisibleAssistantChunk(type: string): boolean {
|| type === 'block-end'
}
-/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
+/** Live Assistant-frame accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
export class PartialAccumulator {
// Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
private blocks: (AssistantBlock | undefined)[] = []
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts
index fd04874615..14094a8e46 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts
@@ -1,9 +1,10 @@
import type { Context } from '@deepseek-ai/cordis'
-import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type {} from '@deepseek-ai/dsh-tools/types'
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
@@ -40,31 +41,29 @@ interface TurnProcessState {
type ConversationEvent = Parameters[0]
-function isChunkRunEvent(event: ConversationEvent): event is ChunkRowEvent {
- return event.type === 'chunkrow/text-chunks'
- || event.type === 'chunkrow/reasoning-chunks'
- || event.type === 'chunkrow/tool-call-chunks'
-}
-
function eventTurn(event: ConversationEvent): number | undefined {
const data = event.data as unknown as { turn?: unknown }
return typeof data.turn === 'number' ? data.turn : undefined
}
-function visibleAssistantEvent(event: ConversationEvent): boolean {
- if (event.type === 'assistant/chunk') {
- const chunk = event.data.chunk
- if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') return chunk.text.trim() !== ''
- if (chunk.type === 'block-start') {
- return chunk.blockType !== 'text'
+function visibleChunk(chunk: StreamChunk): boolean {
+ if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') return chunk.text.trim() !== ''
+ if (chunk.type === 'block-start') {
+ return chunk.blockType !== 'text'
&& chunk.blockType !== 'reasoning'
&& chunk.blockType !== 'tool-call'
- }
- if (chunk.type !== 'block-end') return false
- const block = chunk.block
- if (block.type === 'tool-call') return false
- if (block.type === 'text' || block.type === 'reasoning') return block.text.trim() !== ''
- return true
+ }
+ if (chunk.type !== 'block-end') return false
+ const block = chunk.block
+ if (block.type === 'tool-call') return false
+ if (block.type === 'text' || block.type === 'reasoning') return block.text.trim() !== ''
+ return true
+}
+
+function visibleAssistantEvent(event: ConversationEvent): boolean {
+ if (event.type === 'assistant/live-chunk') return visibleChunk(event.data.chunk)
+ if (event.type === 'assistant/attempt') {
+ return expandAssistantStream(event.data.stream).some(member => visibleChunk(member.chunk))
}
return event.type === 'assistant/message'
&& isAppendSurfaceEvent(event)
@@ -80,15 +79,10 @@ type ProcessEvidence =
| { readonly kind: 'other'; readonly seq: number }
function processEvidence(event: ConversationEvent): ProcessEvidence | undefined {
- if (isChunkRunEvent(event)) {
- if (event.type === 'chunkrow/tool-call-chunks') return undefined
- const firstVisible = event.data.texts.findIndex(text => text.trim() !== '')
- return firstVisible < 0
- ? undefined
- : { kind: 'assistant', seq: event.seq + firstVisible, step: event.data.step }
- }
if (visibleAssistantEvent(event)) {
- if (event.type !== 'assistant/chunk' && event.type !== 'assistant/message') return undefined
+ if (event.type !== 'assistant/live-chunk'
+ && event.type !== 'assistant/message'
+ && event.type !== 'assistant/attempt') return undefined
return { kind: 'assistant', seq: event.seq, step: event.data.step }
}
if (event.type === 'tool/call'
@@ -214,9 +208,9 @@ export const turnProcessDefinition: ConversationNodeDefinition
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
const turn = eventTurn(event)
if (turn === undefined) return null
- if (event.type === 'assistant/chunk'
+ if (event.type === 'assistant/live-chunk'
|| event.type === 'assistant/message'
- || isChunkRunEvent(event)
+ || event.type === 'assistant/attempt'
|| event.type === 'tool/call'
|| event.type === 'tool/result'
|| event.type === 'llm/retry'
@@ -239,8 +233,7 @@ export const turnProcessDefinition: ConversationNodeDefinition
},
update: (context, match) => updateProcessState(context.state, match.event),
publication: (match) => {
- if (isChunkRunEvent(match.event)) return 'animation-frame'
- if (match.event.type === 'assistant/chunk') {
+ if (match.event.type === 'assistant/live-chunk') {
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
}
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts
index 9791ee5ceb..d9a31fe61d 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts
@@ -3,6 +3,8 @@ import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
@@ -38,9 +40,7 @@ interface StepEvidence {
}
function isSessionEvent(event: ConversationMatch['event']): event is SessionEvent {
- return event.type !== 'chunkrow/text-chunks'
- && event.type !== 'chunkrow/reasoning-chunks'
- && event.type !== 'chunkrow/tool-call-chunks'
+ return event.type !== 'assistant/live-chunk'
}
function hasTextAssistant(event: Parameters[0]): boolean {
@@ -50,30 +50,27 @@ function hasTextAssistant(event: Parameters
.some(block => block.kind === 'text' && block.text.trim() !== '')
}
-function chunkHasText(event: Parameters[0]): boolean {
- if (event.type === 'chunkrow/text-chunks') {
- return event.data.texts.some(text => text.trim() !== '')
- }
- if (event.type === 'chunkrow/reasoning-chunks'
- || event.type === 'chunkrow/tool-call-chunks') return false
- if (event.type !== 'assistant/chunk') return false
- const chunk = event.data.chunk
+function chunkHasText(chunk: StreamChunk): boolean {
if (chunk.type === 'text-delta') return chunk.text.trim() !== ''
return chunk.type === 'block-end'
&& chunk.block.type === 'text'
&& chunk.block.text.trim() !== ''
}
+function eventStreamHasText(event: Parameters[0]): boolean {
+ if (event.type === 'assistant/live-chunk') return chunkHasText(event.data.chunk)
+ if (event.type !== 'assistant/attempt') return false
+ return expandAssistantStream(event.data.stream).some(member => chunkHasText(member.chunk))
+}
+
function turnCoordinates(event: Parameters[0]): {
readonly turn: number
readonly step?: number
} | undefined {
if (event.type === 'assistant/message'
- || event.type === 'assistant/chunk'
+ || event.type === 'assistant/attempt'
+ || event.type === 'assistant/live-chunk'
|| event.type === 'step/start'
- || event.type === 'chunkrow/text-chunks'
- || event.type === 'chunkrow/reasoning-chunks'
- || event.type === 'chunkrow/tool-call-chunks'
|| event.type === 'step/end') {
return { turn: event.data.turn, step: event.data.step }
}
@@ -95,13 +92,10 @@ function closingAnchor(context: ConversationNodeContext): number
const coordinates = turnCoordinates(event)
if (coordinates?.step === undefined) continue
const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false }
- if (event.type === 'assistant/chunk'
- || event.type === 'chunkrow/text-chunks'
- || event.type === 'chunkrow/reasoning-chunks'
- || event.type === 'chunkrow/tool-call-chunks') {
+ if (event.type === 'assistant/live-chunk' || event.type === 'assistant/attempt') {
steps.set(coordinates.step, {
...previous,
- streamedText: previous.streamedText || chunkHasText(event),
+ streamedText: previous.streamedText || eventStreamHasText(event),
})
continue
}
diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
index 00484dc6c5..41d497196b 100644
--- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
+++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
@@ -5,15 +5,11 @@ import type {
import type {
SessionEventLikeEntry, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
-import type {
- ChunkRowEvent,
-} from '@deepseek-ai/dsh-api-session-controller/types'
import {
ConversationNodeAssembler,
type ConversationNodeDefinition,
type ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
import { decodeTurnProcess } from '../src/client/contract/turn-process.ts'
@@ -73,34 +69,23 @@ function at(
data: unknown,
extra: Record = {},
): SessionLiveEventEntry {
+ const payload = type === 'assistant/message' && typeof data === 'object' && data !== null
+ ? { ...(data as Record), stream: (data as { stream?: unknown }).stream ?? [] }
+ : data
return {
type: 'event',
event: {
seq,
time: 1_700_000_000_000 + seq,
type,
- data,
+ data: payload,
...extra,
} as unknown as SessionEvent,
}
}
-function chunkEntry(row: ChunkRow): SessionEventLikeEntry {
- return {
- type: 'chunks',
- event: {
- type: `chunkrow/${row.type}`,
- seq: row.seq0,
- time: row.time0,
- data: row.data,
- } as ChunkRowEvent,
- }
-}
-
function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
- return packChunkRuns(entries.map(entry => entry.event)).map((record) => {
- return isChunkRow(record) ? chunkEntry(record) : { type: 'event', event: record }
- })
+ return [...entries]
}
function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
@@ -189,7 +174,7 @@ describe('built-in conversation node Definitions', () => {
at(1, 'turn/start', { turn: 1 }),
at(2, 'user/message', textMessage('user-1', 'navigate here'), { surfaceOp: 'append' }),
at(3, 'step/start', { turn: 1, step: 1 }),
- at(4, 'assistant/chunk', {
+ at(4, 'assistant/live-chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'first' },
@@ -203,7 +188,7 @@ describe('built-in conversation node Definitions', () => {
// Content-only upsert: the node keeps its key, so the rail's preview has to
// follow the in-place update rather than the last structural publication.
- value.append(at(5, 'assistant/chunk', {
+ value.append(at(5, 'assistant/live-chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: ' and more' },
@@ -245,13 +230,13 @@ describe('built-in conversation node Definitions', () => {
step: 1,
source: { kind: 'plugin', plugin: 'context' },
}, { surfaceOp: 'append' }),
- at(4, 'assistant/chunk', {
+ at(4, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}),
- at(5, 'assistant/chunk', {
+ at(5, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'checking' },
}),
- at(6, 'assistant/chunk', {
+ at(6, 'assistant/live-chunk', {
turn: 1,
step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' },
@@ -272,10 +257,10 @@ describe('built-in conversation node Definitions', () => {
}, { surfaceOp: 'append' }))
value.append(at(9, 'step/end', { turn: 1, step: 1 }))
value.append(at(10, 'step/start', { turn: 1, step: 2 }))
- value.append(at(11, 'assistant/chunk', {
+ value.append(at(11, 'assistant/live-chunk', {
turn: 1, step: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'final thinking' },
}))
- value.append(at(12, 'assistant/chunk', {
+ value.append(at(12, 'assistant/live-chunk', {
turn: 1, step: 2, chunk: { type: 'text-delta', index: 1, text: 'final reply' },
}))
value.flush()
@@ -294,7 +279,7 @@ describe('built-in conversation node Definitions', () => {
value.flush()
expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
- value.append(at(14, 'assistant/chunk', {
+ value.append(at(14, 'assistant/live-chunk', {
turn: 1,
step: 2,
chunk: { type: 'text-delta', index: 0, text: 'replacement reply' },
@@ -318,7 +303,7 @@ describe('built-in conversation node Definitions', () => {
}, { surfaceOp: 'append' }),
at(23, 'step/end', { turn: 2, step: 1 }),
at(24, 'step/start', { turn: 2, step: 2 }),
- at(25, 'assistant/chunk', {
+ at(25, 'assistant/live-chunk', {
turn: 2, step: 2, chunk: { type: 'text-delta', index: 0, text: 'crash partial' },
}),
at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }),
@@ -328,7 +313,7 @@ describe('built-in conversation node Definitions', () => {
.toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 })
const partialWindow = assembler([
- at(30, 'assistant/chunk', {
+ at(30, 'assistant/live-chunk', {
turn: 3, step: 4, chunk: { type: 'text-delta', index: 0, text: 'loaded tail' },
}),
at(31, 'step/end', { turn: 3, step: 4 }),
@@ -389,7 +374,7 @@ describe('built-in conversation node Definitions', () => {
'user', 'context',
])
- value.append(at(5, 'assistant/chunk', {
+ value.append(at(5, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}))
value.flush()
@@ -489,7 +474,7 @@ describe('built-in conversation node Definitions', () => {
}),
at(4, 'user/message', steering, { surfaceOp: 'append' }),
at(5, 'step/start', { turn: 1, step: 1 }),
- at(6, 'assistant/chunk', {
+ at(6, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}),
at(7, 'step/end', { turn: 1, step: 1 }),
@@ -548,7 +533,7 @@ describe('built-in conversation node Definitions', () => {
source: { kind: 'plugin', plugin: 'context' },
}, { surfaceOp: 'append' }),
at(3, 'step/start', { turn: 1, step: 1 }),
- at(4, 'assistant/chunk', {
+ at(4, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}),
])
@@ -584,10 +569,10 @@ describe('built-in conversation node Definitions', () => {
const value = assembler([
at(40, 'turn/start', { turn: 4 }),
at(41, 'step/start', { turn: 4, step: 1 }),
- at(42, 'assistant/chunk', {
+ at(42, 'assistant/live-chunk', {
turn: 4, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}),
- at(43, 'assistant/chunk', {
+ at(43, 'assistant/live-chunk', {
turn: 4, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' },
}),
])
@@ -612,7 +597,7 @@ describe('built-in conversation node Definitions', () => {
const value = assembler([
at(50, 'turn/start', { turn: 5 }),
at(51, 'step/start', { turn: 5, step: 1 }),
- at(52, 'assistant/chunk', {
+ at(52, 'assistant/live-chunk', {
turn: 5, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'image' },
}),
])
@@ -631,7 +616,7 @@ describe('built-in conversation node Definitions', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/chunk', {
+ at(3, 'assistant/live-chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'streaming' },
@@ -658,7 +643,7 @@ describe('built-in conversation node Definitions', () => {
const interruptedValue = assembler([
at(10, 'turn/start', { turn: 2 }),
at(11, 'step/start', { turn: 2, step: 1 }),
- at(12, 'assistant/chunk', {
+ at(12, 'assistant/live-chunk', {
turn: 2,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'partial' },
@@ -704,7 +689,7 @@ describe('built-in conversation node Definitions', () => {
const toolOnlyValue = assembler([
at(30, 'turn/start', { turn: 4 }),
at(31, 'step/start', { turn: 4, step: 1 }),
- at(32, 'assistant/chunk', {
+ at(32, 'assistant/live-chunk', {
turn: 4,
step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
@@ -730,7 +715,7 @@ describe('built-in conversation node Definitions', () => {
const interruptedToolOnlyValue = assembler([
at(35, 'turn/start', { turn: 5 }),
at(36, 'step/start', { turn: 5, step: 1 }),
- at(37, 'assistant/chunk', {
+ at(37, 'assistant/live-chunk', {
turn: 5,
step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
@@ -744,7 +729,7 @@ describe('built-in conversation node Definitions', () => {
const retryTimingValue = assembler([
at(50, 'turn/start', { turn: 6 }),
at(51, 'step/start', { turn: 6, step: 1 }),
- at(52, 'assistant/chunk', {
+ at(52, 'assistant/live-chunk', {
turn: 6,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
@@ -754,7 +739,7 @@ describe('built-in conversation node Definitions', () => {
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
failure: { code: 'TRANSPORT', message: 'temporary' },
}),
- at(54, 'assistant/chunk', {
+ at(54, 'assistant/live-chunk', {
turn: 6,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
@@ -769,7 +754,7 @@ describe('built-in conversation node Definitions', () => {
expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
const partialWindow = assembler([
- at(40, 'assistant/chunk', {
+ at(40, 'assistant/live-chunk', {
turn: 5,
step: 2,
chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
@@ -787,43 +772,43 @@ describe('built-in conversation node Definitions', () => {
const runningHistory = [
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/chunk', {
+ at(3, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}, { time: 1_000 }),
- at(4, 'assistant/chunk', {
+ at(4, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}, { time: 1_000 }),
- at(5, 'assistant/chunk', {
+ at(5, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\t' },
}, { time: 995 }),
- at(6, 'assistant/chunk', {
+ at(6, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' },
}, { time: 1_004 }),
- at(7, 'assistant/chunk', {
+ at(7, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: '' },
}),
- at(8, 'assistant/chunk', {
+ at(8, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' },
}),
- at(9, 'assistant/chunk', {
+ at(9, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'ing' },
}),
- at(10, 'assistant/chunk', {
+ at(10, 'assistant/live-chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '' },
}),
- at(11, 'assistant/chunk', {
+ at(11, 'assistant/live-chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '{"x":' },
}),
- at(12, 'assistant/chunk', {
+ at(12, 'assistant/live-chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '1}' },
}),
]
const scalar = assembler(runningHistory)
const packedHistory = packedInputs(runningHistory)
- expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(3)
+ expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(0)
const packed = assembler(packedHistory)
expect(snapshot(packed)).toEqual(snapshot(scalar))
@@ -860,13 +845,13 @@ describe('built-in conversation node Definitions', () => {
const finalizedHistory = [
at(20, 'turn/start', { turn: 2 }),
at(21, 'step/start', { turn: 2, step: 1 }),
- at(22, 'assistant/chunk', {
+ at(22, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}, { time: 2_000 }),
- at(23, 'assistant/chunk', {
+ at(23, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}, { time: 1_999 }),
- at(24, 'assistant/chunk', {
+ at(24, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' },
}, { time: 2_000 }),
at(25, 'llm/retry', {
@@ -874,13 +859,13 @@ describe('built-in conversation node Definitions', () => {
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
failure: { code: 'TRANSPORT', message: 'temporary' },
}),
- at(26, 'assistant/chunk', {
+ at(26, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}),
- at(27, 'assistant/chunk', {
+ at(27, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'second' },
}),
- at(28, 'assistant/chunk', {
+ at(28, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' attempt' },
}),
at(29, 'assistant/message', {
@@ -896,7 +881,7 @@ describe('built-in conversation node Definitions', () => {
const namedToolHistory = [
at(40, 'turn/start', { turn: 3 }),
at(41, 'step/start', { turn: 3, step: 1 }),
- ...[42, 43, 44].map(seq => at(seq, 'assistant/chunk', {
+ ...[42, 43, 44].map(seq => at(seq, 'assistant/live-chunk', {
turn: 3, step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
}, { time: 4_000 + seq - 42 })),
@@ -1357,7 +1342,7 @@ describe('built-in conversation node Definitions', () => {
expect(kinds()).toEqual(['system-prompt', 'user', 'context'])
- value.append(at(6, 'assistant/chunk', {
+ value.append(at(6, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
}))
value.flush()
diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts
index df81e4b7b3..676d10e8b8 100644
--- a/packages/client/ui-conversation/src/client/conversation/assembler.ts
+++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts
@@ -1,6 +1,4 @@
-import type {
- SessionEventLikeEntry, SessionLiveEventEntry,
-} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
ConversationContextReader, ConversationLocationData, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
@@ -129,8 +127,8 @@ function conversationMatch(
location: ConversationMatch['location'],
): ConversationMatch {
if (role === 'start') {
- if (input.type === 'chunks') {
- throw new Error(`conversation Context ${key} received a packed start Match`)
+ if (input.type !== 'event') {
+ throw new Error(`conversation Context ${key} received a transient start Match`)
}
return { event: input.event, role, location }
}
@@ -217,13 +215,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
* @param record - appended Session event entry.
* @returns highest requested publication cadence.
*/
- append(record: SessionLiveEventEntry): ConversationPublication {
+ append(record: SessionEventLikeEntry): ConversationPublication {
const event = record.event
if (this.inputs.has(event.seq)) return 'none'
this.revised.clear()
this.inputs.set(event.seq, record)
let publication: ConversationPublication = 'none'
- if (isLocationBoundary(event.type)) {
+ if (event.type !== 'assistant/live-chunk' && isLocationBoundary(event.type)) {
const previousTimeline = this.locationIndex.snapshot()
const changed = this.locationIndex.appendBoundary(event)
if (this.locationIndex.snapshot() !== previousTimeline) {
diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts
index 9b5f966bcd..8748939fad 100644
--- a/packages/client/ui-conversation/src/client/conversation/location-index.ts
+++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts
@@ -444,7 +444,7 @@ export class ConversationLocationIndex {
* Index one non-boundary tail event without rescanning the window.
* @param event - contiguous appended event.
*/
- appendNonBoundary(event: SessionEvent): void {
+ appendNonBoundary(event: SessionEventLike): void {
const explicit = payloadCoordinates(event)
if (explicit.session === true) {
this.coordinates.set(event.seq, {})
diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts
index 0e95e83465..8a8af4ebc3 100644
--- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts
+++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts
@@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import type {
SessionEventLike, SessionEventLikeEntry, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
-import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
-import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { ConversationNodeAssembler as RuntimeConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -123,14 +123,21 @@ function input(event: SessionEvent): SessionLiveEventEntry {
return { type: 'event', event }
}
-function chunkInput(row: ChunkRow): SessionEventLikeEntry {
- const event = {
- type: `chunkrow/${row.type}`,
- seq: row.seq0,
- time: row.time0,
- data: row.data,
- } as ChunkRowEvent
- return { type: 'chunks', event }
+function transientChunk(
+ seq: number,
+ turn: number,
+ step: number,
+ chunk: StreamChunk,
+): SessionEventLikeEntry {
+ return {
+ type: 'transient',
+ event: {
+ type: 'assistant/live-chunk',
+ seq,
+ time: 1_700_000_000_000 + seq,
+ data: { attemptId: LlmAttemptId('test-attempt'), turn, step, chunk },
+ },
+ }
}
function testSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
@@ -336,16 +343,16 @@ describe('ConversationNodeAssembler', () => {
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
})
- it('keeps one packed Match through replace, Location replay, and Registry rebuild', () => {
+ it('keeps one transient Match through replace, Location replay, and Registry rebuild', () => {
interface State {
readonly updates: readonly string[]
- readonly packedStatus: string | undefined
+ readonly transientStatus: string | undefined
}
const matches = vi.fn((event: SessionEventLike) => {
if (event.type === 'step/start') return { id: '2:3', role: 'start' as const }
if ((event.type as string) === 'probe/update'
- || event.type === 'chunkrow/text-chunks') {
+ || event.type === 'assistant/live-chunk') {
return { id: '2:3', role: 'update' as const }
}
return null
@@ -355,14 +362,14 @@ describe('ConversationNodeAssembler', () => {
context: ConversationNodeContext & { readonly state: State },
match: ConversationMatch,
): State => {
- if (match.event.type === 'chunkrow/text-chunks') {
+ if (match.event.type === 'assistant/live-chunk') {
return {
...context.state,
updates: [
...context.state.updates,
- `packed:${String(match.event.seq)}-${String(match.event.seq + match.event.data.texts.length - 1)}`,
+ `transient:${String(match.event.seq)}:${match.event.data.chunk.type}`,
],
- packedStatus: match.location.kind === 'step'
+ transientStatus: match.location.kind === 'step'
? match.location.step.status
: match.location.kind,
}
@@ -373,9 +380,9 @@ describe('ConversationNodeAssembler', () => {
}
})
const definition: ConversationNodeDefinition = {
- kind: 'packed-probe',
+ kind: 'transient-probe',
match: matches,
- start: () => ({ updates: [], packedStatus: undefined }),
+ start: () => ({ updates: [], transientStatus: undefined }),
update: updates,
target: 'test',
buildViewNode: context => context.state === undefined
@@ -389,21 +396,16 @@ describe('ConversationNodeAssembler', () => {
}),
}
const passive: ConversationNodeDefinition = {
- kind: 'packed-passive',
+ kind: 'transient-passive',
match: passiveMatches,
start: () => null,
update: context => context.state,
}
- const run = chunkInput({
- type: 'text-chunks',
- seq0: SessionSeq(12),
- time0: 1_700_000_000_012,
- data: { turn: 2, step: 3, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] },
- })
+ const delta = transientChunk(12.5, 2, 3, { type: 'text-delta', index: 0, text: 'abc' })
const inputs: SessionEventLikeEntry[] = [
input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 })),
input(at(SessionSeq(11), 'probe/update', { turn: 2, step: 3 })),
- run,
+ delta,
input(at(SessionSeq(15), 'probe/update', { turn: 2, step: 3 })),
]
const assembler = new ConversationNodeAssembler(
@@ -418,15 +420,15 @@ describe('ConversationNodeAssembler', () => {
expect(passiveMatches).toHaveBeenCalledTimes(4)
expect(updates).toHaveBeenCalledTimes(3)
expect(updates.mock.calls.filter(([, match]) => (
- match.event.type === 'chunkrow/text-chunks'
+ match.event.type === 'assistant/live-chunk'
))).toHaveLength(1)
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
- updates: ['event:11', 'packed:12-14', 'event:15'],
- packedStatus: 'open',
+ updates: ['event:11', 'transient:12.5:text-delta', 'event:15'],
+ transientStatus: 'open',
matches: [
{ type: 'step/start', seq: 10 },
{ type: 'probe/update', seq: 11 },
- { type: 'chunkrow/text-chunks', seq: 12 },
+ { type: 'assistant/live-chunk', seq: 12.5 },
{ type: 'probe/update', seq: 15 },
],
})
@@ -435,11 +437,11 @@ describe('ConversationNodeAssembler', () => {
assembler.flush()
expect(updates.mock.calls.filter(([, match]) => (
- match.event.type === 'chunkrow/text-chunks'
+ match.event.type === 'assistant/live-chunk'
))).toHaveLength(2)
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toMatchObject({
- updates: ['event:11', 'packed:12-14', 'event:15'],
- packedStatus: 'closed',
+ updates: ['event:11', 'transient:12.5:text-delta', 'event:15'],
+ transientStatus: 'closed',
})
matches.mockClear()
@@ -452,11 +454,11 @@ describe('ConversationNodeAssembler', () => {
expect(passiveMatches).toHaveBeenCalledTimes(5)
expect(updates).toHaveBeenCalledTimes(3)
expect(updates.mock.calls.filter(([, match]) => (
- match.event.type === 'chunkrow/text-chunks'
+ match.event.type === 'assistant/live-chunk'
))).toHaveLength(1)
})
- it('replays one pending packed Match after prepend supplies its scalar start', () => {
+ it('replays one pending transient Match after prepend supplies its durable start', () => {
const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
const updates = vi.fn((
context: ConversationNodeContext<{ batches: number; status: string }> & {
@@ -468,12 +470,12 @@ describe('ConversationNodeAssembler', () => {
status: match.location.kind === 'step' ? match.location.step.status : match.location.kind,
}))
const definition: ConversationNodeDefinition<{ batches: number; status: string }> = {
- kind: 'packed-pending',
+ kind: 'transient-pending',
match: (event) => {
if (event.type === 'step/start') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
}
- if (event.type === 'chunkrow/reasoning-chunks') {
+ if (event.type === 'assistant/live-chunk') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
}
return null
@@ -492,14 +494,9 @@ describe('ConversationNodeAssembler', () => {
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
- const run = chunkInput({
- type: 'reasoning-chunks',
- seq0: SessionSeq(21),
- time0: 1_700_000_000_021,
- data: { turn: 4, step: 5, index: 0, dt: [0, -1], texts: ['', ' ', 'x'] },
- })
+ const delta = transientChunk(21, 4, 5, { type: 'reasoning-delta', index: 0, text: 'x' })
- assembler.replaceWindow([run], true)
+ assembler.replaceWindow([delta], true)
assembler.flush()
expect(starts).not.toHaveBeenCalled()
@@ -516,14 +513,14 @@ describe('ConversationNodeAssembler', () => {
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
batches: 1,
status: 'open',
- matches: [['step/start', 20], ['chunkrow/reasoning-chunks', 21]],
+ matches: [['step/start', 20], ['assistant/live-chunk', 21]],
})
})
- it('rejects a packed event classified as a Context start', () => {
+ it('rejects a transient event classified as a Context start', () => {
const definition: ConversationNodeDefinition = {
- kind: 'invalid-packed-start',
- match: event => event.type === 'chunkrow/text-chunks'
+ kind: 'invalid-transient-start',
+ match: event => event.type === 'assistant/live-chunk'
? { id: 'one', role: 'start' }
: null,
start: () => null,
@@ -533,15 +530,10 @@ describe('ConversationNodeAssembler', () => {
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView()]),
)
- const run = chunkInput({
- type: 'text-chunks',
- seq0: SessionSeq(1),
- time0: 1_700_000_000_001,
- data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] },
- })
+ const delta = transientChunk(1, 1, 1, { type: 'text-delta', index: 0, text: 'abc' })
- expect(() => assembler.replaceWindow([run], false)).toThrow(
- 'conversation Context 20:invalid-packed-startone received a packed start Match',
+ expect(() => assembler.replaceWindow([delta], false)).toThrow(
+ 'conversation Context 23:invalid-transient-startone received a transient start Match',
)
})
@@ -680,6 +672,7 @@ describe('ConversationNodeAssembler', () => {
)
assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
+ stream: [],
}))], true)
assembler.flush()
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
@@ -722,6 +715,7 @@ describe('ConversationNodeAssembler', () => {
input(at(SessionSeq(40), 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })),
input(at(SessionSeq(50), 'assistant/message', {
turn: 1, step: 1, message: { role: 'assistant', content: [] },
+ stream: [],
})),
], true)
assembler.flush()
@@ -736,6 +730,7 @@ describe('ConversationNodeAssembler', () => {
})))
assembler.append(input(at(SessionSeq(70), 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
+ stream: [],
})))
assembler.flush()
@@ -765,6 +760,7 @@ describe('ConversationNodeAssembler', () => {
)
assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', {
turn: 2, step: 1, message: { role: 'assistant', content: [] },
+ stream: [],
}))], true)
assembler.flush()
@@ -809,7 +805,9 @@ describe('ConversationNodeAssembler', () => {
)
assembler.replaceWindow([
input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
- input(at(SessionSeq(2), 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
+ input(at(SessionSeq(2), 'assistant/message', {
+ turn: 1, step: 1, message: { role: 'assistant', content: [] }, stream: [],
+ })),
], false)
assembler.flush()
@@ -878,7 +876,9 @@ describe('ConversationNodeAssembler', () => {
assembler.replaceWindow([
input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
input(at(SessionSeq(2), 'turn/start', { turn: 1 })),
- input(at(SessionSeq(3), 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
+ input(at(SessionSeq(3), 'assistant/message', {
+ turn: 1, step: 1, message: { role: 'assistant', content: [] }, stream: [],
+ })),
input(at(SessionSeq(4), 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })),
], false)
diff --git a/packages/client/ui-conversation/tests/history-transport.perf.client.ts b/packages/client/ui-conversation/tests/history-transport.perf.client.ts
index c04457e08d..ab4f66cabc 100644
--- a/packages/client/ui-conversation/tests/history-transport.perf.client.ts
+++ b/packages/client/ui-conversation/tests/history-transport.perf.client.ts
@@ -1,4 +1,4 @@
-/** Opt-in synthetic benchmark for packed session-history transport and exact replay. */
+/** Opt-in synthetic benchmark for v2 embedded Assistant stream history. */
import { createHash } from 'node:crypto'
import { createServer, type Server } from 'node:http'
@@ -6,13 +6,15 @@ import { performance } from 'node:perf_hooks'
import { brotliCompressSync, gzipSync } from 'node:zlib'
import { expect, it } from 'vitest'
import { z } from 'zod'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows'
-import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
+import {
+ createAssistantMessage,
+ createUserMessage,
+ expandAssistantStream,
+} from '@deepseek-ai/dsh-llm'
+import type { AssistantStreamRecord } from '@deepseek-ai/dsh-llm'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session/types'
import type {
- ChunkRowEvent,
SessionEventEntry,
SessionHistoryRecord,
SessionWireEvent,
@@ -26,10 +28,10 @@ import type {
ConversationViewNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
-const LOGICAL_EVENTS = 416_756
-const DELTA_EVENTS = 416_176
-const ORDINARY_EVENTS = LOGICAL_EVENTS - DELTA_EVENTS
-const DELTA_RUNS = 116
+const LOGICAL_ITEMS = 416_756
+const STREAM_MEMBERS = 416_176
+const DURABLE_EVENTS = LOGICAL_ITEMS - STREAM_MEMBERS
+const COMPACT_RECORDS = 116
const TIME_ZERO = 1_700_000_000_000
interface Timed {
@@ -58,10 +60,10 @@ interface TransferTimings {
interface FoldState {
readonly blocks: readonly string[]
- readonly deltaCount: number
- readonly lastDeltaSeq?: number
+ readonly memberCount: number
+ readonly lastMemberIndex?: number
readonly firstTokenTime?: number
- readonly firstVisibleSeq?: number
+ readonly firstVisibleIndex?: number
readonly firstVisibleTime?: number
}
@@ -70,14 +72,16 @@ interface FoldSnapshots {
readonly trajectory: unknown
}
-interface RawHistoryValue {
- readonly events: SessionEventEntry[]
+interface HistoryValue {
+ readonly records: SessionHistoryRecord[]
readonly hasMore: boolean
}
-interface PackedHistoryValue {
- readonly records: SessionHistoryRecord[]
- readonly hasMore: boolean
+interface HistoryFixture {
+ readonly rawEvents: readonly SessionEvent[]
+ readonly compactEvents: readonly SessionEvent[]
+ readonly rawRecordCount: number
+ readonly compactRecordCount: number
}
const safeIntegerSchema = z.number().int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER)
@@ -94,70 +98,10 @@ const historyEntrySchema = z.object({
type: z.literal('event'),
event: sessionWireEventSchema,
}).strict()
-const chunkRunBaseSchema = {
- turn: z.number(),
- step: z.number(),
- index: z.number(),
- dt: z.array(safeIntegerSchema),
-}
-const textChunkEventSchema = z.object({
- type: z.enum(['chunkrow/text-chunks', 'chunkrow/reasoning-chunks']),
- seq: safeIntegerSchema.nonnegative(),
- time: safeIntegerSchema,
- data: z.object({
- ...chunkRunBaseSchema,
- texts: z.array(z.string()).min(1),
- }).strict(),
-}).strict()
-const toolCallChunkEventSchema = z.object({
- type: z.literal('chunkrow/tool-call-chunks'),
- seq: safeIntegerSchema.nonnegative(),
- time: safeIntegerSchema,
- data: z.object({
- ...chunkRunBaseSchema,
- id: z.string(),
- name: z.string().optional(),
- args: z.array(z.string()).min(1),
- }).strict(),
-}).strict()
-const chunkEventSchema: z.ZodType = z.discriminatedUnion('type', [
- textChunkEventSchema,
- toolCallChunkEventSchema,
-]).superRefine((event, context) => {
- const members = event.type === 'chunkrow/tool-call-chunks' ? event.data.args : event.data.texts
- if (event.data.dt.length !== members.length - 1) {
- context.addIssue({
- code: 'custom',
- message: 'packed chunk dt length must be one less than member count',
- path: ['data', 'dt'],
- })
- }
- if (members.length - 1 > Number.MAX_SAFE_INTEGER - event.seq) {
- context.addIssue({ code: 'custom', message: 'packed chunk seqs must stay safe integers', path: ['seq'] })
- }
- let time = event.time
- for (let index = 0; index < event.data.dt.length; index++) {
- time += event.data.dt[index] as number
- if (Number.isSafeInteger(time)) continue
- context.addIssue({
- code: 'custom',
- message: 'packed chunk times must stay safe integers',
- path: ['data', 'dt', index],
- })
- break
- }
-}) as z.ZodType
-const packedHistoryValueSchema: z.ZodType = z.object({
- records: z.array(z.union([
- historyEntrySchema,
- z.object({ type: z.literal('chunks'), event: chunkEventSchema }).strict(),
- ])),
+const historyValueSchema: z.ZodType = z.object({
+ records: z.array(historyEntrySchema),
hasMore: z.boolean(),
-}) as z.ZodType
-const rawSessionHistoryValueSchema: z.ZodType = z.object({
- events: z.array(historyEntrySchema),
- hasMore: z.boolean(),
-}) as z.ZodType
+}) as z.ZodType
function timed(run: () => T): Timed {
const start = performance.now()
@@ -188,7 +132,9 @@ async function listen(server: Server): Promise {
})
})
const address = server.address()
- if (address === null || typeof address === 'string') throw new Error('history transport benchmark server has no TCP port')
+ if (address === null || typeof address === 'string') {
+ throw new Error('history transport benchmark server has no TCP port')
+ }
return address.port
}
@@ -274,13 +220,13 @@ function append(
events.push({ type, seq, time: TIME_ZERO + seq, data, ...options } as SessionEvent)
}
-function appendSeparator(events: SessionEvent[], run: number, separator: number): void {
+function appendSeparator(events: SessionEvent[], separator: number): void {
const seq = events.length
events.push({
type: 'benchmark/separator',
seq,
time: TIME_ZERO + seq,
- data: { run, separator },
+ data: { separator },
ignorable: true,
} as SessionEvent)
}
@@ -290,43 +236,62 @@ function fragment(run: number, index: number): string {
return value.toString(36).padStart(7, '0').slice(-2)
}
-/** Build the private sample's event/run cardinality from deterministic synthetic content. */
-function buildEvents(): SessionEvent[] {
- const events: SessionEvent[] = []
- append(events, 'turn/start', { turn: 1 })
- append(events, 'user/message', createUserMessage({
+/** Build equal v2 histories whose single Assistant row uses raw or compact stream records. */
+function buildFixture(): HistoryFixture {
+ const rawStream: AssistantStreamRecord[] = []
+ const compactStream: AssistantStreamRecord[] = []
+ const blocks: { readonly type: 'reasoning'; readonly text: string }[] = []
+ const baseRunLength = Math.floor(STREAM_MEMBERS / COMPACT_RECORDS)
+ const longerRuns = STREAM_MEMBERS % COMPACT_RECORDS
+ let member = 0
+ for (let run = 0; run < COMPACT_RECORDS; run++) {
+ const runLength = baseRunLength + (run < longerRuns ? 1 : 0)
+ const texts = Array.from({ length: runLength }, (_, index) => fragment(run, index))
+ compactStream.push({
+ type: 'reasoning-chunks',
+ time0: TIME_ZERO + member,
+ index: run,
+ dt: Array.from({ length: runLength - 1 }, () => 1),
+ texts,
+ })
+ for (const text of texts) {
+ rawStream.push({
+ type: 'chunk',
+ time: TIME_ZERO + member,
+ chunk: { type: 'reasoning-delta', index: run, text },
+ })
+ member += 1
+ }
+ blocks.push({ type: 'reasoning', text: texts.join('') })
+ }
+
+ const message = createAssistantMessage({
+ content: blocks,
+ source: { provider: 'benchmark', model: 'synthetic-v2' },
+ })
+ const prefix: SessionEvent[] = []
+ append(prefix, 'turn/start', { turn: 1 })
+ append(prefix, 'user/message', createUserMessage({
content: [{ type: 'text', text: 'synthetic history transport benchmark' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
- append(events, 'step/start', { turn: 1, step: 1 })
-
- const baseRunLength = Math.floor(DELTA_EVENTS / DELTA_RUNS)
- const longerRuns = DELTA_EVENTS % DELTA_RUNS
- for (let run = 0; run < DELTA_RUNS; run++) {
- const runLength = baseRunLength + (run < longerRuns ? 1 : 0)
- for (let index = 0; index < runLength; index++) {
- append(events, 'assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: {
- type: 'reasoning-delta',
- index: run,
- text: fragment(run, index),
- },
- })
- }
- const separators = run < 3 ? 4 : 5
- for (let separator = 0; separator < separators; separator++) {
- appendSeparator(events, run, separator)
- }
+ append(prefix, 'step/start', { turn: 1, step: 1 })
+ for (let separator = 0; separator < DURABLE_EVENTS - 4; separator++) {
+ appendSeparator(prefix, separator)
}
- return events
-}
-function memberTime(event: ChunkRowEvent, index: number): number {
- let time = event.time
- for (let cursor = 0; cursor < index; cursor++) time += event.data.dt[cursor] as number
- return time
+ const rawEvents = [...prefix]
+ const compactEvents = [...prefix]
+ append(rawEvents, 'assistant/message', { turn: 1, step: 1, message, stream: rawStream }, { surfaceOp: 'append' })
+ append(compactEvents, 'assistant/message', {
+ turn: 1, step: 1, message, stream: compactStream,
+ }, { surfaceOp: 'append' })
+ return {
+ rawEvents,
+ compactEvents,
+ rawRecordCount: rawStream.length,
+ compactRecordCount: compactStream.length,
+ }
}
function foldDefinition(kind: string, target: string): ConversationNodeDefinition {
@@ -334,55 +299,40 @@ function foldDefinition(kind: string, target: string): ConversationNodeDefinitio
kind,
target,
match: (event) => {
- if (event.type === 'step/start') return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
- if (event.type === 'assistant/chunk' && event.data.chunk.type === 'reasoning-delta') {
- return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
+ if (event.type === 'step/start') {
+ return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
}
- if (event.type === 'chunkrow/reasoning-chunks') {
+ if (event.type === 'assistant/message') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
}
return null
},
- start: () => ({ blocks: [], deltaCount: 0 }),
+ start: () => ({ blocks: [], memberCount: 0 }),
update: (context, match) => {
- if (match.event.type === 'chunkrow/reasoning-chunks') {
- const event = match.event
- const blocks = [...context.state.blocks]
- blocks[event.data.index] = (blocks[event.data.index] ?? '') + event.data.texts.join('')
- const firstToken = event.data.texts.findIndex(text => text !== '')
- const firstVisible = event.data.texts.findIndex(text => text.trim() !== '')
- return {
- ...context.state,
- blocks,
- deltaCount: context.state.deltaCount + event.data.texts.length,
- lastDeltaSeq: event.seq + event.data.texts.length - 1,
- ...context.state.firstTokenTime === undefined && firstToken >= 0
- ? { firstTokenTime: memberTime(event, firstToken) }
- : {},
- ...context.state.firstVisibleSeq === undefined && firstVisible >= 0
- ? {
- firstVisibleSeq: event.seq + firstVisible,
- firstVisibleTime: memberTime(event, firstVisible),
- }
- : {},
+ if (match.event.type !== 'assistant/message') return context.state
+ const blocks = [...context.state.blocks]
+ let memberCount = context.state.memberCount
+ let firstTokenTime = context.state.firstTokenTime
+ let firstVisibleIndex = context.state.firstVisibleIndex
+ let firstVisibleTime = context.state.firstVisibleTime
+ for (const timedChunk of expandAssistantStream(match.event.data.stream)) {
+ if (timedChunk.chunk.type !== 'reasoning-delta') continue
+ const chunk = timedChunk.chunk
+ blocks[chunk.index] = (blocks[chunk.index] ?? '') + chunk.text
+ memberCount += 1
+ firstTokenTime ??= timedChunk.time
+ if (firstVisibleIndex === undefined && blocks.some(block => block.trim() !== '')) {
+ firstVisibleIndex = memberCount
+ firstVisibleTime = timedChunk.time
}
}
- if (match.event.type !== 'assistant/chunk' || match.event.data.chunk.type !== 'reasoning-delta') {
- return context.state
- }
- const chunk = match.event.data.chunk
- const blocks = [...context.state.blocks]
- blocks[chunk.index] = (blocks[chunk.index] ?? '') + chunk.text
- const visible = blocks.some(block => block.trim() !== '')
return {
- ...context.state,
blocks,
- deltaCount: context.state.deltaCount + 1,
- lastDeltaSeq: match.event.seq,
- ...context.state.firstTokenTime === undefined ? { firstTokenTime: match.event.time } : {},
- ...visible && context.state.firstVisibleSeq === undefined
- ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
- : {},
+ memberCount,
+ lastMemberIndex: memberCount,
+ ...(firstTokenTime === undefined ? {} : { firstTokenTime }),
+ ...(firstVisibleIndex === undefined ? {} : { firstVisibleIndex }),
+ ...(firstVisibleTime === undefined ? {} : { firstVisibleTime }),
}
},
buildViewNode: context => context.state === undefined
@@ -416,22 +366,6 @@ function wireEntries(events: readonly SessionEvent[]): SessionEventEntry[] {
return events.map(wireEntry)
}
-function chunkEntry(row: ChunkRow): SessionHistoryRecord {
- return {
- type: 'chunks',
- event: {
- type: `chunkrow/${row.type}`,
- seq: row.seq0,
- time: row.time0,
- data: row.data,
- } as ChunkRowEvent,
- }
-}
-
-function historyRecord(record: SessionEvent | ChunkRow): SessionHistoryRecord {
- return isChunkRow(record) ? chunkEntry(record) : wireEntry(record)
-}
-
function assemble(entries: readonly SessionEventLikeEntry[]): FoldSnapshots {
const definitions = [
foldDefinition('benchmark-chat-assistant', 'chat'),
@@ -454,63 +388,53 @@ function digest(value: unknown): string {
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
}
-it('reports packed history transport and exact replay costs', async () => {
- const fixture = timed(buildEvents)
+it('reports v2 embedded stream compaction and exact replay costs', async () => {
+ const fixture = timed(buildFixture)
+ assemble(historyEntries(wireEntries(fixture.value.compactEvents.slice(0, 100))))
- assemble(historyEntries(wireEntries(fixture.value.slice(0, 1_000))))
const rawHostHeap = sampledPeakHeap((sample) => {
- const entries = wireEntries(fixture.value)
+ const records = wireEntries(fixture.value.rawEvents)
sample()
- const json = JSON.stringify({ events: entries, hasMore: false } satisfies RawHistoryValue)
+ const json = JSON.stringify({ records, hasMore: false } satisfies HistoryValue)
sample()
return Buffer.byteLength(json)
})
- const packedHostHeap = sampledPeakHeap((sample) => {
- const packedEvents = packChunkRuns(fixture.value)
+ const compactHostHeap = sampledPeakHeap((sample) => {
+ const records = wireEntries(fixture.value.compactEvents)
sample()
- const records = packedEvents.map(historyRecord)
- sample()
- const json = JSON.stringify({
- records,
- hasMore: false,
- } satisfies PackedHistoryValue)
+ const json = JSON.stringify({ records, hasMore: false } satisfies HistoryValue)
sample()
return Buffer.byteLength(json)
})
- const rawEntries = timed(() => wireEntries(fixture.value))
- const packed = timed(() => packChunkRuns(fixture.value))
- const packedRecords = timed(() => packed.value.map(historyRecord))
- const rawValue: RawHistoryValue = { events: rawEntries.value, hasMore: false }
- const packedValue: PackedHistoryValue = {
- records: packedRecords.value,
- hasMore: false,
- }
-
+ const rawEntries = timed(() => wireEntries(fixture.value.rawEvents))
+ const compactEntries = timed(() => wireEntries(fixture.value.compactEvents))
+ const rawValue: HistoryValue = { records: rawEntries.value, hasMore: false }
+ const compactValue: HistoryValue = { records: compactEntries.value, hasMore: false }
const rawJson = timed(() => JSON.stringify(rawValue))
- const packedJson = timed(() => JSON.stringify(packedValue))
+ const compactJson = timed(() => JSON.stringify(compactValue))
const rawGzip = timed(() => gzipSync(rawJson.value).byteLength)
- const packedGzip = timed(() => gzipSync(packedJson.value).byteLength)
+ const compactGzip = timed(() => gzipSync(compactJson.value).byteLength)
const rawBrotli = timed(() => brotliCompressSync(rawJson.value).byteLength)
- const packedBrotli = timed(() => brotliCompressSync(packedJson.value).byteLength)
+ const compactBrotli = timed(() => brotliCompressSync(compactJson.value).byteLength)
const rawTransfer = await loopbackTransfer(rawJson.value)
- const packedTransfer = await loopbackTransfer(packedJson.value)
+ const compactTransfer = await loopbackTransfer(compactJson.value)
const rawClientHeap = sampledPeakHeap((sample) => {
const wire: unknown = JSON.parse(rawJson.value)
sample()
- const parsed = rawSessionHistoryValueSchema.parse(wire)
+ const parsed = historyValueSchema.parse(wire)
sample()
- const prepared = historyEntries(parsed.events)
+ const prepared = historyEntries(parsed.records)
sample()
const folded = assemble(prepared)
sample()
return digest(folded)
})
- const packedClientHeap = sampledPeakHeap((sample) => {
- const wire: unknown = JSON.parse(packedJson.value)
+ const compactClientHeap = sampledPeakHeap((sample) => {
+ const wire: unknown = JSON.parse(compactJson.value)
sample()
- const parsed = packedHistoryValueSchema.parse(wire)
+ const parsed = historyValueSchema.parse(wire)
sample()
const prepared = historyEntries(parsed.records)
sample()
@@ -520,102 +444,102 @@ it('reports packed history transport and exact replay costs', async () => {
})
const parsedRaw = timed((): unknown => JSON.parse(rawJson.value))
- const parsedPacked = timed((): unknown => JSON.parse(packedJson.value))
- const rawValidation = timed(() => rawSessionHistoryValueSchema.parse(parsedRaw.value))
- const packedValidation = timed(() => packedHistoryValueSchema.parse(parsedPacked.value))
- const rawPreparation = timed(() => historyEntries(rawValidation.value.events))
- const packedPreparation = timed(() => historyEntries(packedValidation.value.records))
+ const parsedCompact = timed((): unknown => JSON.parse(compactJson.value))
+ const rawValidation = timed(() => historyValueSchema.parse(parsedRaw.value))
+ const compactValidation = timed(() => historyValueSchema.parse(parsedCompact.value))
+ const rawPreparation = timed(() => historyEntries(rawValidation.value.records))
+ const compactPreparation = timed(() => historyEntries(compactValidation.value.records))
- assemble(rawPreparation.value.slice(0, 1_000))
- assemble(packedPreparation.value)
+ assemble(rawPreparation.value.slice(0, 100))
+ assemble(compactPreparation.value)
const rawFold = timed(() => assemble(rawPreparation.value))
- const packedFold = timed(() => assemble(packedPreparation.value))
+ const compactFold = timed(() => assemble(compactPreparation.value))
const rawBytes = Buffer.byteLength(rawJson.value)
- const packedBytes = Buffer.byteLength(packedJson.value)
- const packedRows = packed.value.filter(isChunkRow)
- expect(fixture.value).toHaveLength(LOGICAL_EVENTS)
- expect(fixture.value.filter(event => event.type !== 'assistant/chunk')).toHaveLength(ORDINARY_EVENTS)
- expect(packedRows).toHaveLength(DELTA_RUNS)
- expect(packed.value).toHaveLength(696)
- expect(packedPreparation.value).toHaveLength(696)
- expect(digest(packedFold.value)).toBe(digest(rawFold.value))
- expect(packedClientHeap.value).toBe(rawClientHeap.value)
+ const compactBytes = Buffer.byteLength(compactJson.value)
+ expect(fixture.value.rawEvents).toHaveLength(DURABLE_EVENTS)
+ expect(fixture.value.compactEvents).toHaveLength(DURABLE_EVENTS)
+ expect(fixture.value.rawRecordCount).toBe(STREAM_MEMBERS)
+ expect(fixture.value.compactRecordCount).toBe(COMPACT_RECORDS)
+ expect(rawPreparation.value).toHaveLength(DURABLE_EVENTS)
+ expect(compactPreparation.value).toHaveLength(DURABLE_EVENTS)
+ expect(digest(compactFold.value)).toBe(digest(rawFold.value))
+ expect(compactClientHeap.value).toBe(rawClientHeap.value)
expect(rawHostHeap.value).toBe(rawBytes)
- expect(packedHostHeap.value).toBe(packedBytes)
- expect(packedBytes).toBeLessThan(rawBytes)
+ expect(compactHostHeap.value).toBe(compactBytes)
+ expect(compactBytes).toBeLessThan(rawBytes)
const rawResponseMs = rawEntries.ms + rawJson.ms
- const packedResponseMs = packed.ms + packedRecords.ms + packedJson.ms
+ const compactResponseMs = compactEntries.ms + compactJson.ms
const rawClientMs = parsedRaw.ms + rawValidation.ms + rawPreparation.ms + rawFold.ms
- const packedClientMs = parsedPacked.ms + packedValidation.ms + packedPreparation.ms + packedFold.ms
+ const compactClientMs = parsedCompact.ms + compactValidation.ms + compactPreparation.ms + compactFold.ms
const rawSyntheticApiWaitMs = rawResponseMs + rawTransfer.totalMs + parsedRaw.ms + rawValidation.ms
- const packedSyntheticApiWaitMs = packedResponseMs + packedTransfer.totalMs + parsedPacked.ms + packedValidation.ms
+ const compactSyntheticApiWaitMs = compactResponseMs + compactTransfer.totalMs
+ + parsedCompact.ms + compactValidation.ms
const rawSyntheticReadyMs = rawResponseMs + rawTransfer.totalMs + rawClientMs
- const packedSyntheticReadyMs = packedResponseMs + packedTransfer.totalMs + packedClientMs
+ const compactSyntheticReadyMs = compactResponseMs + compactTransfer.totalMs + compactClientMs
process.stdout.write(`HISTORY_TRANSPORT_PERF_RESULT ${JSON.stringify({
fixture: {
buildMs: rounded(fixture.ms),
- logicalEvents: fixture.value.length,
- ordinaryEvents: ORDINARY_EVENTS,
- deltaEvents: DELTA_EVENTS,
- deltaRuns: packedRows.length,
- packedRecords: packed.value.length,
- conversationInputs: packedPreparation.value.length,
+ logicalItems: LOGICAL_ITEMS,
+ durableEvents: DURABLE_EVENTS,
+ streamMembers: STREAM_MEMBERS,
+ rawStreamRecords: fixture.value.rawRecordCount,
+ compactStreamRecords: fixture.value.compactRecordCount,
+ historyRecords: compactPreparation.value.length,
},
bytes: {
rawJson: rawBytes,
- packedJson: packedBytes,
- jsonReductionPct: reduction(rawBytes, packedBytes),
+ compactJson: compactBytes,
+ jsonReductionPct: reduction(rawBytes, compactBytes),
rawGzip: rawGzip.value,
- packedGzip: packedGzip.value,
- gzipReductionPct: reduction(rawGzip.value, packedGzip.value),
+ compactGzip: compactGzip.value,
+ gzipReductionPct: reduction(rawGzip.value, compactGzip.value),
rawBrotli: rawBrotli.value,
- packedBrotli: packedBrotli.value,
- brotliReductionPct: reduction(rawBrotli.value, packedBrotli.value),
+ compactBrotli: compactBrotli.value,
+ brotliReductionPct: reduction(rawBrotli.value, compactBrotli.value),
},
memory: {
samples: 3,
rawHostAdditionalHeapPeakBytes: rawHostHeap.medianPeakBytes,
- packedHostAdditionalHeapPeakBytes: packedHostHeap.medianPeakBytes,
- hostReductionPct: reduction(rawHostHeap.medianPeakBytes, packedHostHeap.medianPeakBytes),
+ compactHostAdditionalHeapPeakBytes: compactHostHeap.medianPeakBytes,
+ hostReductionPct: reduction(rawHostHeap.medianPeakBytes, compactHostHeap.medianPeakBytes),
rawClientAdditionalHeapPeakBytes: rawClientHeap.medianPeakBytes,
- packedClientAdditionalHeapPeakBytes: packedClientHeap.medianPeakBytes,
- clientReductionPct: reduction(rawClientHeap.medianPeakBytes, packedClientHeap.medianPeakBytes),
+ compactClientAdditionalHeapPeakBytes: compactClientHeap.medianPeakBytes,
+ clientReductionPct: reduction(rawClientHeap.medianPeakBytes, compactClientHeap.medianPeakBytes),
rawHostPeakSamples: rawHostHeap.peakBytes,
- packedHostPeakSamples: packedHostHeap.peakBytes,
+ compactHostPeakSamples: compactHostHeap.peakBytes,
rawClientPeakSamples: rawClientHeap.peakBytes,
- packedClientPeakSamples: packedClientHeap.peakBytes,
+ compactClientPeakSamples: compactClientHeap.peakBytes,
},
host: {
rawEntryWrapMs: rounded(rawEntries.ms),
- packMs: rounded(packed.ms),
- packedRecordWrapMs: rounded(packedRecords.ms),
+ compactEntryWrapMs: rounded(compactEntries.ms),
rawStringifyMs: rounded(rawJson.ms),
- packedStringifyMs: rounded(packedJson.ms),
+ compactStringifyMs: rounded(compactJson.ms),
rawGzipMs: rounded(rawGzip.ms),
- packedGzipMs: rounded(packedGzip.ms),
+ compactGzipMs: rounded(compactGzip.ms),
rawBrotliMs: rounded(rawBrotli.ms),
- packedBrotliMs: rounded(packedBrotli.ms),
+ compactBrotliMs: rounded(compactBrotli.ms),
rawResponseMs: rounded(rawResponseMs),
- packedResponseMs: rounded(packedResponseMs),
- responseReductionPct: reduction(rawResponseMs, packedResponseMs),
+ compactResponseMs: rounded(compactResponseMs),
+ responseReductionPct: reduction(rawResponseMs, compactResponseMs),
},
transport: {
samples: 5,
rawHeadersMs: rounded(rawTransfer.headersMs),
- packedHeadersMs: rounded(packedTransfer.headersMs),
+ compactHeadersMs: rounded(compactTransfer.headersMs),
rawBodyMs: rounded(rawTransfer.bodyMs),
- packedBodyMs: rounded(packedTransfer.bodyMs),
+ compactBodyMs: rounded(compactTransfer.bodyMs),
rawTotalMs: rounded(rawTransfer.totalMs),
- packedTotalMs: rounded(packedTransfer.totalMs),
- totalReductionPct: reduction(rawTransfer.totalMs, packedTransfer.totalMs),
+ compactTotalMs: rounded(compactTransfer.totalMs),
+ totalReductionPct: reduction(rawTransfer.totalMs, compactTransfer.totalMs),
rawSamples: rawTransfer.samples.map(sample => ({
headersMs: rounded(sample.headersMs),
bodyMs: rounded(sample.bodyMs),
totalMs: rounded(sample.totalMs),
})),
- packedSamples: packedTransfer.samples.map(sample => ({
+ compactSamples: compactTransfer.samples.map(sample => ({
headersMs: rounded(sample.headersMs),
bodyMs: rounded(sample.bodyMs),
totalMs: rounded(sample.totalMs),
@@ -623,67 +547,64 @@ it('reports packed history transport and exact replay costs', async () => {
},
client: {
rawParseMs: rounded(parsedRaw.ms),
- packedParseMs: rounded(parsedPacked.ms),
+ compactParseMs: rounded(parsedCompact.ms),
rawValidationMs: rounded(rawValidation.ms),
- packedValidationMs: rounded(packedValidation.ms),
+ compactValidationMs: rounded(compactValidation.ms),
rawPrepareMs: rounded(rawPreparation.ms),
- packedPrepareMs: rounded(packedPreparation.ms),
+ compactPrepareMs: rounded(compactPreparation.ms),
rawFoldMs: rounded(rawFold.ms),
- packedFoldMs: rounded(packedFold.ms),
+ compactFoldMs: rounded(compactFold.ms),
rawHistoryMs: rounded(rawClientMs),
- packedHistoryMs: rounded(packedClientMs),
- historyReductionPct: reduction(rawClientMs, packedClientMs),
+ compactHistoryMs: rounded(compactClientMs),
+ historyReductionPct: reduction(rawClientMs, compactClientMs),
},
combined: {
rawSyntheticApiWaitMs: rounded(rawSyntheticApiWaitMs),
- packedSyntheticApiWaitMs: rounded(packedSyntheticApiWaitMs),
- syntheticApiWaitReductionPct: reduction(rawSyntheticApiWaitMs, packedSyntheticApiWaitMs),
+ compactSyntheticApiWaitMs: rounded(compactSyntheticApiWaitMs),
+ syntheticApiWaitReductionPct: reduction(rawSyntheticApiWaitMs, compactSyntheticApiWaitMs),
rawSyntheticReadyMs: rounded(rawSyntheticReadyMs),
- packedSyntheticReadyMs: rounded(packedSyntheticReadyMs),
- syntheticReadyReductionPct: reduction(rawSyntheticReadyMs, packedSyntheticReadyMs),
+ compactSyntheticReadyMs: rounded(compactSyntheticReadyMs),
+ syntheticReadyReductionPct: reduction(rawSyntheticReadyMs, compactSyntheticReadyMs),
},
})}\n`)
}, 600_000)
-it('reports compact folding cost for long whitespace-prefix runs', () => {
- historyEntries([{
- type: 'chunks',
- event: {
- type: 'chunkrow/reasoning-chunks',
- seq: 0,
- time: TIME_ZERO,
- data: { turn: 1, step: 1, index: 0, dt: [], texts: ['x'] },
- },
- }])
+it('reports compact folding cost for long whitespace-prefix streams', () => {
const results = [10_000, 20_000, 40_000].map((members) => {
- const record: SessionHistoryRecord = {
- type: 'chunks',
- event: {
- type: 'chunkrow/reasoning-chunks',
- seq: 1,
- time: TIME_ZERO + 1,
- data: {
- turn: 1,
- step: 1,
- index: 0,
- dt: Array.from({ length: members - 1 }, () => 1),
- texts: Array.from({ length: members }, (_, index) => index === members - 1 ? 'x' : ' '),
- },
- },
- }
+ const stream: readonly AssistantStreamRecord[] = [{
+ type: 'reasoning-chunks',
+ time0: TIME_ZERO + 1,
+ index: 0,
+ dt: Array.from({ length: members - 1 }, () => 1),
+ texts: Array.from({ length: members }, (_, index) => index === members - 1 ? 'x' : ' '),
+ }]
const start = wireEntry({
type: 'step/start',
seq: SessionSeq(0),
time: TIME_ZERO,
data: { turn: 1, step: 1 },
})
- const inputs = historyEntries([start, record])
+ const message = wireEntry({
+ type: 'assistant/message',
+ seq: SessionSeq(1),
+ time: TIME_ZERO + members,
+ data: {
+ turn: 1,
+ step: 1,
+ message: createAssistantMessage({
+ content: [{ type: 'reasoning', text: `${' '.repeat(members - 1)}x` }],
+ source: { provider: 'benchmark', model: 'synthetic-v2' },
+ }),
+ stream,
+ },
+ } as SessionEvent<'assistant/message'>)
+ const inputs = historyEntries([start, message])
const folded = assemble(inputs)
const samplesMs = Array.from({ length: 5 }, () => timed(() => assemble(inputs)).ms)
expect((folded.chat as readonly { readonly data: FoldState }[])[0]?.data).toMatchObject({
- deltaCount: members,
- lastDeltaSeq: members,
- firstVisibleSeq: members,
+ memberCount: members,
+ lastMemberIndex: members,
+ firstVisibleIndex: members,
firstVisibleTime: TIME_ZERO + members,
})
return {
diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
index 72d78bb01e..2562601fb6 100644
--- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
+++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
@@ -1,10 +1,11 @@
import type { Context } from '@deepseek-ai/cordis'
-import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
AssistantBlock, AssistantMessageNode, ConversationLocation,
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
PartialAssistant, RequestView,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
import { trajectoryNode } from './trajectory-definition-common.ts'
import {
displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks,
@@ -47,12 +48,6 @@ interface AssistantState {
readonly stepEnd: ConversationMatch | undefined
}
-function isChunkRunEvent(event: ConversationMatch['event']): event is ChunkRowEvent {
- return event.type === 'chunkrow/text-chunks'
- || event.type === 'chunkrow/reasoning-chunks'
- || event.type === 'chunkrow/tool-call-chunks'
-}
-
function initialState(
turn: number,
step: number,
@@ -118,9 +113,12 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue
}
}
-function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState {
- if (match.event.type !== 'assistant/chunk') return state
- const chunk = match.event.data.chunk
+function updateChunk(
+ state: AssistantState,
+ chunk: StreamChunk,
+ seq: number,
+ time: number,
+): AssistantState {
if (chunk.type === 'usage') {
return { ...state, sawChunk: true, usage: addUsage(state.usage, chunk.usage) }
}
@@ -185,94 +183,23 @@ function updateChunk(state: AssistantState, match: ConversationMatch): Assistant
blocks,
visibleBlocks,
...(visibleBlocks > 0 && state.firstVisibleSeq === undefined
- ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
+ ? { firstVisibleSeq: seq, firstVisibleTime: time }
: {}),
...(isTokenDelta(chunk) && state.firstTokenTime === undefined
- ? { firstTokenTime: match.event.time }
+ ? { firstTokenTime: time }
: {}),
}
}
-interface ChunkRunBoundaries {
- readonly firstTokenTime: number | undefined
- readonly firstVisible: { readonly seq: number; readonly time: number } | undefined
-}
-
-function chunkRunBoundaries(
- event: ChunkRowEvent,
- needsToken: boolean,
- needsVisible: boolean,
- visibleFromStart: boolean,
-): ChunkRunBoundaries {
- const fragments = event.type === 'chunkrow/tool-call-chunks' ? event.data.args : event.data.texts
- const nameStartsToken = event.type === 'chunkrow/tool-call-chunks'
- && Object.hasOwn(event.data, 'name')
- let firstTokenTime: number | undefined
- let firstVisible: ChunkRunBoundaries['firstVisible']
- let time = event.time
- for (let index = 0; index < fragments.length; index++) {
- const fragment = fragments[index] as string
- if (needsToken && firstTokenTime === undefined && (nameStartsToken || fragment !== '')) {
- firstTokenTime = time
- }
- if (needsVisible && firstVisible === undefined
- && (visibleFromStart
- || (event.type !== 'chunkrow/tool-call-chunks' && fragment.trim() !== ''))) {
- firstVisible = { seq: event.seq + index, time }
- }
- if ((!needsToken || firstTokenTime !== undefined)
- && (!needsVisible || firstVisible !== undefined)) break
- time += event.data.dt[index] ?? 0
- }
- return { firstTokenTime, firstVisible }
-}
-
-function updateChunkRun(state: AssistantState, event: ChunkRowEvent): AssistantState {
- const blocks = [...state.blocks]
- const previous = blocks[event.data.index]
- const previousVisible = blockIsVisible(previous)
- let visibleFromStart = state.visibleBlocks - Number(previousVisible) > 0
- if (event.type === 'chunkrow/text-chunks') {
- const text = previous?.kind === 'text' ? previous.text : ''
- visibleFromStart ||= text.trim() !== ''
- blocks[event.data.index] = { kind: 'text', text: text + event.data.texts.join('') }
- } else if (event.type === 'chunkrow/reasoning-chunks') {
- const text = previous?.kind === 'reasoning' ? previous.text : ''
- visibleFromStart ||= text.trim() !== ''
- blocks[event.data.index] = { kind: 'reasoning', text: text + event.data.texts.join('') }
- } else {
- const base = previous?.kind === 'tool-call'
- ? previous
- : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
- blocks[event.data.index] = {
- kind: 'tool-call',
- callId: base.callId || String(event.data.id),
- name: Object.hasOwn(event.data, 'name') ? event.data.name as string : base.name,
- argsRaw: base.argsRaw + event.data.args.join(''),
- }
- }
- const boundaries = chunkRunBoundaries(
- event,
- state.firstTokenTime === undefined,
- state.firstVisibleSeq === undefined,
- visibleFromStart,
- )
- const visibleBlocks = state.visibleBlocks
- - Number(previousVisible)
- + Number(blockIsVisible(blocks[event.data.index]))
- return {
- ...state,
- sawChunk: true,
- blocks,
- visibleBlocks,
- ...(boundaries.firstVisible === undefined ? {} : {
- firstVisibleSeq: boundaries.firstVisible.seq,
- firstVisibleTime: boundaries.firstVisible.time,
- }),
- ...(boundaries.firstTokenTime === undefined ? {} : {
- firstTokenTime: boundaries.firstTokenTime,
- }),
+function updateEmbedded(
+ state: AssistantState,
+ event: Extract,
+): AssistantState {
+ let next = state
+ for (const member of expandAssistantStream(event.data.stream)) {
+ next = updateChunk(next, member.chunk, event.seq, member.time)
}
+ return next
}
function closedBoundary(
@@ -290,23 +217,14 @@ function closedBoundary(
function fallbackState(context: ConversationNodeContext): AssistantState | undefined {
let state: AssistantState | undefined
for (const match of context.matches) {
- if (isChunkRunEvent(match.event)) {
- state ??= initialState(
- match.event.data.turn,
- match.event.data.step,
- match.event.seq,
- match.event.time,
- false,
- )
- state = updateChunkRun(state, match.event)
- continue
- }
const event = match.event
- if (event.type === 'assistant/chunk') {
+ if (event.type === 'assistant/live-chunk') {
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
- state = updateChunk(state, match)
- } else if (event.type === 'assistant/message') {
+ state = updateChunk(state, event.data.chunk, event.seq, event.time)
+ } else if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
+ state = updateEmbedded(state, event)
+ if (event.type === 'assistant/attempt') continue
const blocks = toAssistantBlocks(event.data.message.content)
state = {
...state,
@@ -409,15 +327,13 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition
if (event.type === 'step/start') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
}
- if (event.type === 'assistant/chunk'
+ if (event.type === 'assistant/live-chunk'
|| event.type === 'assistant/message'
+ || event.type === 'assistant/attempt'
|| event.type === 'llm/retry'
|| event.type === 'step/end') {
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
}
- if (isChunkRunEvent(event)) {
- return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
- }
return null
},
start: (_context, match) => {
@@ -433,16 +349,19 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition
)
},
update: (context, match) => {
- if (isChunkRunEvent(match.event)) return updateChunkRun(context.state, match.event)
- if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
+ if (match.event.type === 'assistant/live-chunk') {
+ return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
+ }
+ if (match.event.type === 'assistant/attempt') return updateEmbedded(context.state, match.event)
if (match.event.type === 'assistant/message') {
+ const streamed = updateEmbedded(context.state, match.event)
const blocks = toAssistantBlocks(match.event.data.message.content)
return {
- ...context.state,
+ ...streamed,
blocks,
visibleBlocks: countVisibleBlocks(blocks),
final: match,
- usage: context.state.usage ?? match.event.data.usage,
+ usage: streamed.usage ?? match.event.data.usage,
}
}
if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
@@ -470,8 +389,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition
},
publication: (match) => {
if (match.event.type === 'step/start') return 'none'
- if (isChunkRunEvent(match.event)) return 'animation-frame'
- if (match.event.type !== 'assistant/chunk') return 'immediate'
+ if (match.event.type !== 'assistant/live-chunk') return 'immediate'
const type = match.event.data.chunk.type
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
},
diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
index 81fa4d92df..e4358f91aa 100644
--- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
+++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts
@@ -3,14 +3,10 @@ import { describe, expect, it } from 'vitest'
import type {
SessionEventLikeEntry, SessionLiveEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
-import type {
- ChunkRowEvent,
-} from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationNodeAssembler, inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
@@ -61,34 +57,23 @@ function at(
data: unknown,
extra: Record = {},
): SessionLiveEventEntry {
+ const payload = type === 'assistant/message' && typeof data === 'object' && data !== null
+ ? { ...(data as Record), stream: (data as { stream?: unknown }).stream ?? [] }
+ : data
return {
type: 'event',
event: {
seq,
time: 1_700_000_000_000 + seq,
type,
- data,
+ data: payload,
...extra,
} as unknown as SessionEvent,
}
}
-function chunkEntry(row: ChunkRow): SessionEventLikeEntry {
- return {
- type: 'chunks',
- event: {
- type: `chunkrow/${row.type}`,
- seq: row.seq0,
- time: row.time0,
- data: row.data,
- } as ChunkRowEvent,
- }
-}
-
function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
- return packChunkRuns(entries.map(entry => entry.event)).map((record) => {
- return isChunkRow(record) ? chunkEntry(record) : { type: 'event', event: record }
- })
+ return [...entries]
}
function assembler(events: readonly SessionEventLikeEntry[]): ConversationNodeAssembler {
@@ -121,12 +106,12 @@ describe('Trajectory conversation Definitions', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/chunk', {
+ at(3, 'assistant/live-chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
}),
- at(4, 'assistant/chunk', {
+ at(4, 'assistant/live-chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
@@ -152,7 +137,7 @@ describe('Trajectory conversation Definitions', () => {
delayMs: 25,
failure: { code: 'TRANSPORT', message: 'temporary failure' },
}))
- value.append(at(6, 'assistant/chunk', {
+ value.append(at(6, 'assistant/live-chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
@@ -184,40 +169,40 @@ describe('Trajectory conversation Definitions', () => {
const runningHistory = [
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/chunk', {
+ at(3, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}),
- at(4, 'assistant/chunk', {
+ at(4, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}),
- at(5, 'assistant/chunk', {
+ at(5, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' },
}),
- at(6, 'assistant/chunk', {
+ at(6, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: '' },
}),
- at(7, 'assistant/chunk', {
+ at(7, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' },
}),
- at(8, 'assistant/chunk', {
+ at(8, 'assistant/live-chunk', {
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'ing' },
}),
- at(9, 'assistant/chunk', {
+ at(9, 'assistant/live-chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '' },
}),
- at(10, 'assistant/chunk', {
+ at(10, 'assistant/live-chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '{"x":' },
}),
- at(11, 'assistant/chunk', {
+ at(11, 'assistant/live-chunk', {
turn: 1, step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '1}' },
}),
]
const runningScalar = snapshot(assembler(runningHistory))
const packedHistory = packedInputs(runningHistory)
- expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(3)
+ expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(0)
const runningPacked = snapshot(assembler(packedHistory))
expect(runningPacked).toEqual(runningScalar)
expect(runningPacked.partial?.blocks).toEqual([
@@ -246,16 +231,16 @@ describe('Trajectory conversation Definitions', () => {
const finalizedHistory = [
at(20, 'turn/start', { turn: 2 }),
at(21, 'step/start', { turn: 2, step: 1 }),
- at(22, 'assistant/chunk', {
+ at(22, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}, { time: 3_000 }),
- at(23, 'assistant/chunk', {
+ at(23, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
}, { time: 3_000 }),
- at(24, 'assistant/chunk', {
+ at(24, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' },
}, { time: 2_998 }),
- at(25, 'assistant/chunk', {
+ at(25, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
}),
at(26, 'llm/retry', {
@@ -263,13 +248,13 @@ describe('Trajectory conversation Definitions', () => {
policyKey: 'test-normal', retry: 1, maxRetries: 2, delayMs: 25,
failure: { code: 'TRANSPORT', message: 'temporary failure' },
}),
- at(27, 'assistant/chunk', {
+ at(27, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
}),
- at(28, 'assistant/chunk', {
+ at(28, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'second' },
}),
- at(29, 'assistant/chunk', {
+ at(29, 'assistant/live-chunk', {
turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' attempt' },
}),
at(30, 'assistant/message', {
@@ -292,7 +277,7 @@ describe('Trajectory conversation Definitions', () => {
const namedToolHistory = [
at(40, 'turn/start', { turn: 3 }),
at(41, 'step/start', { turn: 3, step: 1 }),
- ...[42, 43, 44].map(seq => at(seq, 'assistant/chunk', {
+ ...[42, 43, 44].map(seq => at(seq, 'assistant/live-chunk', {
turn: 3, step: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
}, { time: 4_000 + seq - 42 })),
diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
index e6ed88274b..76ddb5f021 100644
--- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
+++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts
@@ -124,6 +124,7 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
})
}
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createMessage({
@@ -161,6 +162,7 @@ function toolConversation(): Session {
})
}
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createMessage({
@@ -209,6 +211,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
reason: 'initial',
})
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -583,6 +586,7 @@ describe('pressure measurement and retention', () => {
reason: 'initial',
})
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -745,6 +749,7 @@ describe('pressure measurement and retention', () => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -1095,6 +1100,7 @@ describe('compaction region transaction', () => {
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -1944,6 +1950,7 @@ describe('route-priced image pressure', () => {
})
}
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createMessage({
diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
index f7b44e1940..63236e2d56 100644
--- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
+++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts
@@ -201,6 +201,7 @@ function overflowHistorySeed(): readonly SessionEvent[] {
}), { surfaceOp: 'append' })
session.append('step/start', { turn, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createMessage({
diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
index 4aa8d27cde..ef275d68d7 100644
--- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
+++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
@@ -186,6 +186,7 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
})
}
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createAssistantMessage({
diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
index 2fc6bb9dd4..d99d243617 100644
--- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
+++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
@@ -53,6 +53,7 @@ function appendToolStep(
})
session.append('step/start', { turn, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn,
step: 1,
message: createMessage({
diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts
index 19b5a460c5..1516d5d687 100644
--- a/packages/compaction/compaction/tests/tool-pairing.spec.ts
+++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts
@@ -31,6 +31,7 @@ function closedToolStep(): Session {
source: { kind: 'user' },
}), SURFACE)
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -66,6 +67,7 @@ describe('tool-pairing boundaries', () => {
const open = Session.create(SessionId('open-tool-step'))
open.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -83,6 +85,7 @@ describe('tool-pairing boundaries', () => {
it('requires every result from a multiple-call assistant message', () => {
const session = Session.create(SessionId('multiple-calls'))
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -121,6 +124,7 @@ describe('tool-pairing boundaries', () => {
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
const midStep = Session.create(SessionId('neutral-mid-step'))
midStep.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -213,6 +217,7 @@ describe('tool-pairing cache refresh', () => {
{
type: 'assistant/message', seq: SessionSeq(1), time: 1,
data: {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -282,6 +287,7 @@ describe('tool-pairing cache refresh', () => {
{
type: 'assistant/message', seq: SessionSeq(5), time: 5,
data: {
+ stream: [],
turn: 2,
step: 1,
message: createMessage({
diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts
index acdaa384f2..04b637a985 100644
--- a/packages/context/session-reference/tests/session-reference.spec.ts
+++ b/packages/context/session-reference/tests/session-reference.spec.ts
@@ -83,6 +83,7 @@ function appendConversation(session: Session): void {
const oldAssistant = session.append(
'assistant/message',
{
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -152,6 +153,7 @@ function appendConversation(session: Session): void {
session.append(
'assistant/message',
{
+ stream: [],
turn: 2,
step: 1,
message: createMessage({
@@ -190,6 +192,7 @@ function appendConversation(session: Session): void {
session.append(
'assistant/message',
{
+ stream: [],
turn: 2,
step: 2,
message: createMessage({
@@ -203,10 +206,16 @@ function appendConversation(session: Session): void {
},
{ surfaceOp: 'append' },
)
- session.append('assistant/chunk', {
+ session.append('assistant/attempt', {
turn: 2,
step: 2,
- chunk: { type: 'text-delta', index: 0, text: 'unfinished answer' },
+ stream: [{
+ type: 'text-chunks',
+ time0: 0,
+ index: 0,
+ dt: [],
+ texts: ['unfinished answer'],
+ }],
})
}
@@ -704,6 +713,7 @@ describe('session reference discovery and preparation', () => {
source.append(
'assistant/message',
{
+ stream: [],
turn: 3,
step: 1,
message: createMessage({
@@ -805,6 +815,7 @@ describe('session reference discovery and preparation', () => {
const later = source.append(
'assistant/message',
{
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index 4dca7a045e..7c9e25a9a5 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -18,7 +18,6 @@ import type {
import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import {
- BlockAssembler,
LlmError,
createAssistantMessage,
errorChain,
@@ -27,7 +26,7 @@ import {
import { deepFreeze } from '@deepseek-ai/dsh-util-values'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { createScope } from '@deepseek-ai/dsh-scope'
-import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
+import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
@@ -362,8 +361,6 @@ export class ReactLoopAgent implements Agent {
signal,
)
startsRequestSeries = false
- const assembler = new BlockAssembler()
- const chunkSeqs: SessionSeq[] = []
const live = new AssistantStreamAttempt(
this.session.id,
++this.assistantAttemptCounter,
@@ -372,25 +369,23 @@ export class ReactLoopAgent implements Agent {
step,
(frame) => { this.dispatch.emit('agent/assistant-stream', { frame }) },
)
- let liveStarted = false
+ let started = false
try {
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
signal.throwIfAborted()
live.start()
- liveStarted = true
+ started = true
for await (const chunk of stream) {
signal.throwIfAborted()
- const legacyChunkSeq = this.session.append('assistant/chunk', { turn, step, chunk }).seq
- chunkSeqs.push(legacyChunkSeq)
- assembler.push(chunk)
- live.push(chunk, legacyChunkSeq)
+ live.push(chunk)
}
signal.throwIfAborted()
} catch (error: unknown) {
+ if (!started) throw error
if (signal.aborted) {
- const content = assembler.interruptedBlocks()
+ const content = live.interruptedBlocks()
if (content.length > 0) {
- this.session.append('assistant/message', {
+ live.settle('assistant/message', () => this.session.append('assistant/message', {
turn,
step,
message: createAssistantMessage({
@@ -398,16 +393,29 @@ export class ReactLoopAgent implements Agent {
source: { provider: request.provider, model: request.model },
}),
interrupted: true,
- ...assembler.usage === undefined ? {} : { usage: assembler.usage },
- }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
+ ...live.usage === undefined ? {} : { usage: live.usage },
+ stream: live.stream,
+ }, { surfaceOp: 'append' }).seq)
+ } else {
+ live.settle(
+ 'assistant/attempt',
+ () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq,
+ )
}
+ } else {
+ live.settle(
+ 'assistant/attempt',
+ () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq,
+ )
}
- if (liveStarted) live.end('aborted')
throw error
}
- const finish = assembler.finish
+ const finish = live.finish
if (finish.kind === 'error' || finish.kind === 'aborted') {
- live.end('aborted')
+ live.settle(
+ 'assistant/attempt',
+ () => this.session.append('assistant/attempt', { turn, step, stream: live.stream }).seq,
+ )
const action = await this.dispatch.waterfall(
'agent/request-error', {
turn,
@@ -427,24 +435,23 @@ export class ReactLoopAgent implements Agent {
}
const message = createAssistantMessage({
- content: assembler.blocks(),
+ content: live.blocks(),
source: {
provider: request.provider,
model: request.model,
- ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
+ ...live.replayState !== undefined ? { replayState: live.replayState } : {},
},
})
- this.session.append(
+ live.settle(
'assistant/message',
- {
+ () => this.session.append('assistant/message', {
turn,
step,
message,
- ...assembler.usage === undefined ? {} : { usage: assembler.usage },
- },
- { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
+ ...live.usage === undefined ? {} : { usage: live.usage },
+ stream: live.stream,
+ }, { surfaceOp: 'append' }).seq,
)
- live.end('committed')
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
const toolCalls = message.content.filter(block => block.type === 'tool-call')
diff --git a/packages/core/agent-loop/src/assistant-stream.ts b/packages/core/agent-loop/src/assistant-stream.ts
index 3fffcb472d..fe74cfb153 100644
--- a/packages/core/agent-loop/src/assistant-stream.ts
+++ b/packages/core/agent-loop/src/assistant-stream.ts
@@ -1,12 +1,23 @@
-/** Process-local assistant attempt framing for live consumers. */
+/** Process-local assistant attempt framing and durable stream accumulation. */
-import { LlmAttemptId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import {
+ AssistantStreamAccumulator,
+ BlockAssembler,
+ LlmAttemptId,
+ type AssistantStreamRecord,
+ type ContentBlock,
+ type FinishReason,
+ type ReplayEnvelope,
+ type StreamChunk,
+ type TokenUsage,
+} from '@deepseek-ai/dsh-llm'
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
-import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import type { SessionEventMap, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
-/** Folds one model attempt into ordered transient frames. */
+/** Folds one model attempt into one compact stream plus ordered transient frames. */
export class AssistantStreamAttempt {
- private readonly legacyChunkSeqs: SessionSeq[] = []
+ private readonly accumulator = new AssistantStreamAccumulator()
+ private readonly assembler = new BlockAssembler()
private index = 0
/** Process-local attempt identity. */
readonly attemptId: LlmAttemptId
@@ -42,28 +53,83 @@ export class AssistantStreamAttempt {
})
}
- /** Publish one chunk only after its durable v1 record has appended. */
- push(chunk: StreamChunk, legacyChunkSeq: SessionSeq): void {
- this.legacyChunkSeqs.push(legacyChunkSeq)
+ /** Snapshot one chunk once, then feed durable compaction, assembly, and live publication. */
+ push(chunk: StreamChunk): void {
+ const timed = this.accumulator.push({ time: Date.now(), chunk })
+ this.assembler.push(timed.chunk)
this.emit({
type: 'chunk',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index++,
- chunk,
- legacyChunkSeq,
+ time: timed.time,
+ chunk: timed.chunk,
})
}
- /** Publish terminal settlement after the matching durable assistant message commits. */
- end(outcome: 'committed' | 'aborted'): void {
+ /**
+ * Publish terminal settlement after the matching durable event commits.
+ * @param eventType - durable settlement type.
+ * @param append - synchronous durable append returning its committed seq.
+ */
+ settle(
+ eventType: 'assistant/message' | 'assistant/attempt',
+ append: () => SessionSeq,
+ ): void {
+ let seq: SessionSeq
+ try {
+ seq = append()
+ } catch (error: unknown) {
+ this.abandoned()
+ throw error
+ }
this.emit({
type: 'end',
attemptId: this.attemptId,
revision: this.nextRevision(),
index: this.index,
- outcome,
- legacyChunkSeqs: [...this.legacyChunkSeqs],
+ outcome: { kind: 'committed', eventType, seq },
})
}
+
+ /** Publish abandonment when no durable attempt event can be committed. */
+ private abandoned(): void {
+ this.emit({
+ type: 'end',
+ attemptId: this.attemptId,
+ revision: this.nextRevision(),
+ index: this.index,
+ outcome: { kind: 'abandoned' },
+ })
+ }
+
+ /** Exact compact stream for the final durable event. */
+ get stream(): SessionEventMap['assistant/attempt']['stream'] {
+ return [...this.accumulator.snapshot()] as AssistantStreamRecord[]
+ }
+
+ /** Canonical completed-message blocks from the same chunks. */
+ blocks(): ContentBlock[] {
+ return this.assembler.blocks()
+ }
+
+ /** Safe visible prefix when cancellation interrupts the attempt. */
+ interruptedBlocks(): ContentBlock[] {
+ return this.assembler.interruptedBlocks()
+ }
+
+ /** Latest adapter-reported usage in the stream. */
+ get usage(): TokenUsage | undefined {
+ return this.assembler.usage
+ }
+
+ /** Terminal reason, defaulting to stop when the stream omitted one. */
+ get finish(): FinishReason {
+ return this.assembler.finish
+ }
+
+ /** Replay state carried by the terminal finish record. */
+ get replayState(): ReplayEnvelope | undefined {
+ return this.assembler.replayState
+ }
}
diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts
index 4c148d663d..be7696b88b 100644
--- a/packages/core/agent-loop/tests/cancel.spec.ts
+++ b/packages/core/agent-loop/tests/cancel.spec.ts
@@ -1,4 +1,4 @@
-import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek-ai/dsh-llm'
/**
* Tests for the queue-aware `Agent.cancel()` primitive. The default clears
* queued and steering work, while `keepInbox` preserves pending input for a
@@ -492,14 +492,17 @@ describe('Agent.cancel()', () => {
await waitForIdle(ctx, agent)
// The prefix the user watched stream is committed as the step's message,
- // carrying the truncation marker and citing exactly the chunk events that
- // delivered it.
+ // carrying the truncation marker and exact embedded stream that delivered it.
const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')
expect(message?.type === 'assistant/message' ? message.data.message.content : undefined)
.toEqual([{ type: 'text', text: 'partial' }])
expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true)
- const chunkSeqs = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk').map(e => e.seq)
- expect(message?.sourceEventSeqs).toEqual(chunkSeqs)
+ expect(message?.type === 'assistant/message'
+ ? expandAssistantStream(message.data.stream).some(member => (
+ member.chunk.type === 'text-delta' && member.chunk.text === 'partial'
+ ))
+ : false).toBe(true)
+ expect(message?.sourceEventSeqs).toBeUndefined()
const types = agent.session.snapshotEvents().map(e => e.type)
expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end'))
expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end'))
@@ -587,7 +590,7 @@ describe('Agent.cancel()', () => {
expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted')
})
- it('retry discards the failed attempt; the final message cites only its own chunks', async () => {
+ it('retry retains the failed attempt while the final message embeds only its own stream', async () => {
const adapter = new MockAdapter([
[
{ type: 'block-start', index: 0, blockType: 'text' },
@@ -609,13 +612,44 @@ describe('Agent.cancel()', () => {
expect(message.type === 'assistant/message' ? message.data.message.content : undefined)
.toEqual([{ type: 'text', text: 'recovered' }])
expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined()
- // The abandoned attempt's chunks stay out of the completion's source set.
- const doomedSeqs = agent.session.snapshotEvents()
- .filter(e => e.type === 'assistant/chunk'
- && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial')
- .map(e => e.seq)
- expect(doomedSeqs).toHaveLength(1)
- expect(message.sourceEventSeqs).not.toContain(doomedSeqs[0])
+ const failed = agent.session.snapshotEvents().find(e => e.type === 'assistant/attempt')
+ expect(failed?.type === 'assistant/attempt'
+ ? expandAssistantStream(failed.data.stream).some(member => (
+ member.chunk.type === 'text-delta' && member.chunk.text === 'doomed partial'
+ ))
+ : false).toBe(true)
+ expect(message.type === 'assistant/message'
+ ? expandAssistantStream(message.data.stream).some(member => (
+ member.chunk.type === 'text-delta' && member.chunk.text === 'doomed partial'
+ ))
+ : true).toBe(false)
+ })
+
+ it('retains a partial attempt when stream middleware rejects without cancellation', async () => {
+ const failure = new Error('provider transport failed')
+ const adapter = new MockAdapter([[
+ { type: 'block-start', index: 0, blockType: 'text' },
+ { type: 'text-delta', index: 0, text: 'partial before failure' },
+ ]])
+ const ctx = await harness(adapter)
+ const agent = ctx.agentLoop.create(SessionId('provider-stream-rejection'), { provider: 'mock', model: 'mock' })
+ ctx.on('llm/stream', async function* (_options, next) {
+ for await (const chunk of next()) {
+ yield chunk
+ if (chunk.type === 'text-delta') throw failure
+ }
+ })
+
+ send(agent, 'go')
+ await waitForIdle(ctx, agent)
+
+ const attempt = agent.session.snapshotEvents().find(event => event.type === 'assistant/attempt')
+ expect(attempt?.type === 'assistant/attempt'
+ ? expandAssistantStream(attempt.data.stream).map(member => member.chunk)
+ : []).toContainEqual({ type: 'text-delta', index: 0, text: 'partial before failure' })
+ expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({
+ type: 'turn/end', data: { reason: { kind: 'error', error: { message: failure.message } } },
+ })
})
it('cancel before any visible content finalizes nothing', async () => {
@@ -646,7 +680,7 @@ describe('Agent.cancel()', () => {
// cancel check (the one that must closeStep() to balance the already-open
// step) — distinct from a turn-start cancel, caught before the step opens.
let streamed = false
- ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
+ ctx.on('agent/assistant-stream', ({ frame }) => { if (frame.type === 'chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'step/start') agent.cancel({ kind: 'user' })
})
@@ -686,7 +720,7 @@ describe('Agent.cancel()', () => {
let disposalDone: Promise | undefined
let streamed = false
- ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
+ ctx.on('agent/assistant-stream', ({ frame }) => { if (frame.type === 'chunk') streamed = true })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'step/start') disposalDone = handle.dispose()
})
@@ -739,7 +773,7 @@ describe('Agent.cancel()', () => {
// `agent/status` is synchronous, so cancellation can land before the
// durable turn-start commit and must drop the reserved work.
let streamed = false
- ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
+ ctx.on('agent/assistant-stream', ({ frame }) => { if (frame.type === 'chunk') streamed = true })
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'running') agent.cancel({ kind: 'user' })
})
diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts
index ab338d3b01..ae90678a5f 100644
--- a/packages/core/agent-loop/tests/contract-regressions.spec.ts
+++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts
@@ -1200,7 +1200,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'step/end')).toBe(false)
- expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
+ expect(e.some(x => x.type === 'assistant/attempt')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
})
@@ -1248,7 +1248,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'step/end')).toBe(false)
- expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
+ expect(e.some(x => x.type === 'assistant/attempt')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
@@ -1297,7 +1297,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
- expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
+ expect(e.some(x => x.type === 'assistant/attempt')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
})
@@ -1344,13 +1344,13 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
.toEqual(['turn/start', 'turn/end'])
expect(e.some(x => x.type === 'step/start')).toBe(false)
- expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
+ expect(e.some(x => x.type === 'assistant/attempt')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
})
- it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
+ it('disposal during assembly does not leak an LLM call or append an Assistant settlement', { timeout: 15000 }, async () => {
// The key assertion from the original bug report: after disposal, no
- // assistant/chunk or assistant/message appears — the turn ends disposed
+ // assistant/attempt or assistant/message appears — the turn ends disposed
// before any model interaction.
const adapter = new MockAdapter([textResponse('should not appear')])
let releaseAssemble!: () => void
@@ -1390,7 +1390,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
.toEqual(['turn/start', 'turn/end'])
expect(e.find(x => x.type === 'turn/end')?.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
- expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
+ expect(e.some(x => x.type === 'assistant/attempt')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
})
diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts
index 6c9ecc2020..f0b38e961f 100644
--- a/packages/core/agent-loop/tests/coverage-edges.spec.ts
+++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts
@@ -296,13 +296,13 @@ describe('stream failure edges', () => {
const agent = ctx.agentLoop.create(SessionId('stream-no-facts'), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async () => { recoveries += 1 })
- // A pre-commit chunk veto throws INSIDE the stream-consumption try, but it
- // is not an adapter-boundary failure, so llmFailureOf yields no facts.
+ // A pre-commit durable-settlement veto is not an adapter-boundary failure,
+ // so it is not offered to request recovery.
let vetoed = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
- if (event.type === 'assistant/chunk' && !vetoed) {
+ if (event.type === 'assistant/message' && !vetoed) {
vetoed = true
throw new Error('reject the first chunk')
}
diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts
index 5c691c7ddc..006d85ee1b 100644
--- a/packages/core/agent-loop/tests/loop.spec.ts
+++ b/packages/core/agent-loop/tests/loop.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk } from '@deepseek-ai/dsh-llm'
+import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -52,7 +52,7 @@ function userTexts(agent: Agent): string[] {
}
describe('agent loop', () => {
- it('publishes one dense live assistant attempt while retaining durable v1 chunks', async () => {
+ it('publishes one dense live attempt and commits one v2 message with the exact embedded stream', async () => {
const ctx = await harness(new MockAdapter([textResponse('live')]))
const agent = ctx.agentLoop.create(SessionId('live-assistant-attempt'), {
provider: 'mock',
@@ -63,7 +63,7 @@ describe('agent loop', () => {
ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => {
if (subject !== agent) return
frames.push(frame)
- if (frame.type === 'end' && frame.outcome === 'committed') {
+ if (frame.type === 'end' && frame.outcome.kind === 'committed') {
committedAfterMessage = agent.session.snapshotEvents().at(-1)?.type === 'assistant/message'
}
})
@@ -85,18 +85,51 @@ describe('agent loop', () => {
(frame): frame is Extract => frame.type === 'chunk',
)
expect(chunks.map(frame => frame.index)).toEqual(chunks.map((_frame, index) => index))
- const durableChunks = agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk')
- expect(chunks).toHaveLength(durableChunks.length)
+ expect(agent.session.snapshotEvents().some(event => (event.type as string) === 'assistant/chunk')).toBe(false)
+ const message = agent.session.snapshotEvents().findLast(event => event.type === 'assistant/message')
+ expect(message?.type === 'assistant/message'
+ ? expandAssistantStream(message.data.stream).map(member => member.chunk)
+ : undefined).toStrictEqual(chunks.map(frame => frame.chunk))
const end = frames.at(-1)
expect(end).toMatchObject({
- type: 'end', outcome: 'committed', index: chunks.length,
+ type: 'end',
+ index: chunks.length,
+ outcome: { kind: 'committed', eventType: 'assistant/message', seq: message?.seq },
})
if (end?.type === 'end') {
expect(committedAfterMessage).toBe(true)
- expect(end.legacyChunkSeqs).toEqual(durableChunks.map(event => event.seq))
}
})
+ it('abandons the live row when durable Assistant settlement is rejected', async () => {
+ const ctx = await harness(new MockAdapter([textResponse('cannot commit')]))
+ const agent = ctx.agentLoop.create(SessionId('abandoned-assistant-attempt'), {
+ provider: 'mock',
+ model: 'mock',
+ })
+ const frames: AssistantStreamFrame[] = []
+ ctx.on('agent/assistant-stream', ({ agent: subject, frame }) => {
+ if (subject === agent) frames.push(frame)
+ })
+ const append = agent.session.append.bind(agent.session)
+ Object.defineProperty(agent.session, 'append', {
+ configurable: true,
+ value: (...args: Parameters): ReturnType => {
+ if (args[0] === 'assistant/message') throw new Error('settlement rejected')
+ return Reflect.apply(append, agent.session, args) as ReturnType
+ },
+ })
+
+ send(agent, 'stream this')
+ await waitForIdle(ctx, agent)
+
+ expect(frames.at(-1)).toMatchObject({
+ type: 'end',
+ outcome: { kind: 'abandoned' },
+ })
+ expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false)
+ })
+
it('does not emit an end frame when prepared dispatch throws before start', async () => {
const ctx = await harness(new MockAdapter([]))
const agent = ctx.agentLoop.create(SessionId('assistant-dispatch-throw'), {
@@ -181,6 +214,25 @@ describe('agent loop', () => {
})).toThrow()
})
+ it('rejects concurrent maintenance while one job owns the Agent', async () => {
+ const ctx = await harness(new MockAdapter([textResponse('unused')]))
+ const agent = ctx.agentLoop.create(SessionId('concurrent-maintenance'), {
+ provider: 'mock', model: 'mock',
+ })
+ const started = Promise.withResolvers()
+ const finish = Promise.withResolvers()
+ const active = agent.runMaintenance(async () => {
+ started.resolve(undefined)
+ await finish.promise
+ })
+ await started.promise
+
+ expect(() => agent.runMaintenance(async () => {})).toThrow('already has active work')
+
+ finish.resolve(undefined)
+ await active
+ })
+
it('cancels queued wakeup work together with an active maintenance task', async () => {
const adapter = new MockAdapter([textResponse('park reply')])
const ctx = await harness(adapter)
@@ -631,7 +683,7 @@ describe('agent loop', () => {
}])
})
- it('records raw chunks for replay as assistant/chunk session events', async () => {
+ it('records exact replay chunks inside the durable assistant message', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -639,12 +691,12 @@ describe('agent loop', () => {
send(agent, 'hi')
await waitForIdle(ctx, agent)
- const chunkEvents = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk')
+ const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')
+ const chunks = message?.type === 'assistant/message' ? expandAssistantStream(message.data.stream) : []
// textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7
- expect(chunkEvents).toHaveLength(7)
- // replay: chunk events alone re-assemble to the recorded assistant message
- const deltaText = chunkEvents
- .flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : [])
+ expect(chunks).toHaveLength(7)
+ const deltaText = chunks
+ .map(member => member.chunk)
.filter((c: StreamChunk): c is Extract => c.type === 'text-delta')
.map(c => c.text)
.join('')
@@ -1067,7 +1119,9 @@ describe('agent loop', () => {
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
const chunks = frames.filter(frame => frame.type === 'chunk')
expect(frames.at(-1)).toMatchObject({
- type: 'end', outcome: 'aborted', index: chunks.length,
+ type: 'end',
+ index: chunks.length,
+ outcome: { kind: 'committed', eventType: 'assistant/message' },
})
})
@@ -1202,7 +1256,7 @@ describe('agent loop', () => {
// Empty content still needs an assistant/message to carry usage; derivation
// skips that host so it does not create a spurious assistant turn.
const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')
- expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
+ expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toMatchObject({
turn: 1,
step: 1,
message: {
@@ -1242,7 +1296,7 @@ describe('agent loop', () => {
expect(reasons).toEqual([{ kind: 'max-tokens' }])
const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')!
- expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
+ expect(assistant.type === 'assistant/message' && assistant.data).toMatchObject({
turn: 1,
step: 1,
message: {
@@ -1252,7 +1306,8 @@ describe('agent loop', () => {
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
})
- expect(assistant.sourceEventSeqs?.length).toBeGreaterThan(0)
+ expect(assistant.sourceEventSeqs).toBeUndefined()
+ expect(assistant.type === 'assistant/message' ? assistant.data.stream.length : 0).toBeGreaterThan(0)
expect(agent.session.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
@@ -1276,7 +1331,7 @@ describe('agent loop', () => {
expect(reasons).toEqual([{ kind: 'completed' }])
const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')!
- expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
+ expect(assistant.type === 'assistant/message' && assistant.data).toMatchObject({
turn: 1,
step: 1,
message: {
@@ -1286,7 +1341,8 @@ describe('agent loop', () => {
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
})
- expect(assistant.sourceEventSeqs?.length).toBe(1)
+ expect(assistant.sourceEventSeqs).toBeUndefined()
+ expect(assistant.type === 'assistant/message' ? assistant.data.stream.length : 0).toBe(1)
expect(agent.session.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
@@ -1444,11 +1500,10 @@ describe('agent loop', () => {
const turns: number[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
- // queue two messages while idle — first starts turn 1 immediately;
- // queue the second during turn 1 when the first assistant chunk streams
+ // Queue the second from the first turn's durable Assistant settlement.
let queued = false
ctx.on('session/event', (_s, event) => {
- if (event.type === 'assistant/chunk' && !queued) {
+ if (event.type === 'assistant/message' && !queued) {
queued = true
queueMicrotask(() => { send(agent, 'second message') })
}
@@ -1606,7 +1661,7 @@ describe('agent loop', () => {
send(agent, 'run')
await waitForIdle(ctx, agent)
- const replayed = ctx.sessions.create(SessionId('replayed'), { seed: agent.session.snapshotEvents() })
+ const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.snapshotEvents()] })
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
// event-by-event identity of types over the inherited prefix
expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual(
diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
index d6332965ec..7c9ee6c523 100644
--- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts
+++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts
@@ -721,19 +721,19 @@ describe('request stability across the loop', () => {
adapter.requests.forEach((request, index) => {
const stepStart = stepStarts[index]!
- const firstChunk = events.find(e =>
- e.type === 'assistant/chunk'
+ const settlement = events.find(e =>
+ (e.type === 'assistant/message' || e.type === 'assistant/attempt')
&& e.data.turn === stepStart.data.turn
&& e.data.step === stepStart.data.step,
)!
// Messages: the entered batch is logged after step/start, so rebuild the
// complete dispatch prefix through a completely fresh Session.
- const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, firstChunk.seq)))
+ const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, settlement.seq)))
expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
// Header: the latest request/header snapshot up to this step's dispatch
- // (its header event sits between step/start and the first chunk).
- const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
+ // (its header event sits between step/start and the Assistant settlement).
+ const header = foldRequestHeader(events.slice(0, settlement.seq))!
expect(request.model).toBe(header.config.model)
expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
expect(request.system).toEqual(header.system)
diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts
index 3004fb14b0..aa367a7093 100644
--- a/packages/core/agent-loop/tests/resume.spec.ts
+++ b/packages/core/agent-loop/tests/resume.spec.ts
@@ -182,7 +182,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
version: SESSION_FORMAT_VERSION,
id: sessionId,
})
- expect((await readdir(dirname(v0Path))).sort()).toEqual(['session.jsonl', 'session.v1.jsonl'])
+ expect((await readdir(dirname(v0Path))).sort()).toEqual(['session.jsonl', 'session.v2.jsonl'])
handle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'new question' }],
diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml
index d678232ceb..b3d3288111 100644
--- a/packages/core/agent/README.i18n.yaml
+++ b/packages/core/agent/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
-README.md: 11218f796675d4361e507ab4305c926c2756edb2
-README.zh.md: e15f2fd8295a5891c69814a3e02beff1e2c2b5a1
+README.md: 1a5392cd3d83c4b56150d053aa37e3e4b49d7360
+README.zh.md: 357ca7210f786a54796c8f24801cc15e47ea90d5
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index 11218f7966..1a5392cd3d 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -64,7 +64,7 @@ await handle.agent.whenIdle()
### Intercept or observe work in flight
-The `agent/*` events let plugins act on live work without depending on the loop package. `agent/pre-step` can reject a proposed step or replace the messages entering it; `agent/request-error` lets a listener retry a failed model request; `agent/turn-stopping` runs before an otherwise completed turn closes and can steer to keep it open. `agent/assistant-stream` carries one process-local assistant attempt's ordered frames after their durable v1 records append; `start` records the attempt's safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, and `end.index` is the next chunk position. These frames are presentation data, not a replay source. `agent/status`, `agent/created`, and `agent/disposed` drive UI and coordination state, and the per-message `agent/inbox/*` notifications keep inbox projections in sync. Exact signatures, dispatch modes, and payload contracts live in the generated region of the [core subsystem page](../../../docs/subsystems/core.md#cordis-surface).
+The `agent/*` events let plugins act on live work without depending on the loop package. `agent/pre-step` can reject a proposed step or replace the messages entering it; `agent/request-error` lets a listener retry a failed model request; `agent/turn-stopping` runs before an otherwise completed turn closes and can steer to keep it open. `agent/assistant-stream` carries one process-local Assistant attempt's ordered start, transient chunk, and end frames. Start records the safe-integer wall-clock `startedTime`, chunk indexes are dense from zero, and `end.index` is the next chunk position. The loop commits the complete compact stream as one `assistant/message` or `assistant/attempt` before a committed end frame, so the live event remains presentation data rather than the replay source. `agent/status`, `agent/created`, and `agent/disposed` drive UI and coordination state, and the per-message `agent/inbox/*` notifications keep inbox projections in sync. Exact signatures, dispatch modes, and payload contracts live in the generated region of the [core subsystem page](../../../docs/subsystems/core.md#cordis-surface).
-----
diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md
index e15f2fd829..357ca7210f 100644
--- a/packages/core/agent/README.zh.md
+++ b/packages/core/agent/README.zh.md
@@ -64,7 +64,7 @@ await handle.agent.whenIdle()
### 拦截或观察进行中的工作
-`agent/*` 事件让插件无需依赖循环包即可作用于实时工作。`agent/pre-step` 可以拒绝拟进入的步骤或替换进入它的消息;`agent/request-error` 让监听器重试失败的模型请求;`agent/turn-stopping` 在本可完成的轮次关闭前运行,并可通过 steer 使其保持打开。`agent/assistant-stream` 携带一个进程本地 assistant 尝试在其持久 v1 记录追加后的有序帧;`start` 把该尝试的壁钟时间记录为安全整数 `startedTime`,chunk index 从零开始连续递增,`end.index` 则是下一个 chunk 位置。这些帧是呈现数据,不是重放来源。`agent/status`、`agent/created` 与 `agent/disposed` 驱动 UI 与协调状态,逐消息的 `agent/inbox/*` 通知则让收件箱投影保持同步。确切签名、分发 mode 与 payload 约定见 [core 子系统页](../../../docs/subsystems/core.zh.md#cordis-surface) 的生成区块。
+`agent/*` 事件让插件无需依赖循环包即可作用于实时工作。`agent/pre-step` 可以拒绝拟进入的步骤或替换进入它的消息;`agent/request-error` 让监听器重试失败的模型请求;`agent/turn-stopping` 在本可完成的轮次关闭前运行,并可通过 steer 使其保持打开。`agent/assistant-stream` 携带一个进程本地 Assistant attempt 的有序 start、瞬态 chunk 与 end frame。start 会把壁钟时间记录为安全整数 `startedTime`,chunk index 从零开始密集递增,`end.index` 则是下一个 chunk 位置。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 `assistant/message` 或 `assistant/attempt`,因此 live event 仍是呈现数据而非重放来源。`agent/status`、`agent/created` 与 `agent/disposed` 驱动 UI 与协调状态,逐消息的 `agent/inbox/*` 通知则让收件箱投影保持同步。确切签名、分发 mode 与 payload 约定见 [core 子系统页](../../../docs/subsystems/core.zh.md#cordis-surface) 的生成区块。
-----
diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts
index f2a9f86b69..21e45482a6 100644
--- a/packages/core/agent/src/runtime-types.ts
+++ b/packages/core/agent/src/runtime-types.ts
@@ -88,9 +88,9 @@ export type AssistantStreamFrame =
readonly revision: number
/** Dense zero-based position within the attempt. */
readonly index: number
+ /** Safe-integer timestamp reused by the durable embedded stream. */
+ readonly time: number
readonly chunk: StreamChunk
- /** Matching durable v1 `assistant/chunk` record for duplicate suppression. */
- readonly legacyChunkSeq: SessionSeq
}
| {
readonly type: 'end'
@@ -98,10 +98,14 @@ export type AssistantStreamFrame =
readonly revision: number
/** Number of chunk frames emitted by this attempt. */
readonly index: number
- /** The durable assistant message committed before this notification. */
- readonly outcome: 'committed' | 'aborted'
- /** Every durable v1 chunk represented by this attempt. */
- readonly legacyChunkSeqs: readonly SessionSeq[]
+ /** Durable settlement committed before this notification, or live abandonment without one. */
+ readonly outcome:
+ | {
+ readonly kind: 'committed'
+ readonly eventType: 'assistant/message' | 'assistant/attempt'
+ readonly seq: SessionSeq
+ }
+ | { readonly kind: 'abandoned' }
}
declare module './types.ts' {
@@ -302,9 +306,9 @@ declare module '@deepseek-ai/cordis' {
*/
'agent/request-error'(this: Scoped, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise): Promise
/**
- * Process-local assistant-stream publication. The loop appends each v1
- * `assistant/chunk` before the matching chunk frame and appends the final
- * `assistant/message` before a committed end frame.
+ * Process-local assistant-stream publication. Chunk frames are transient;
+ * the loop appends one final v2 `assistant/message` or `assistant/attempt`
+ * with the same stream before a committed end frame.
* @param payload.agent - the agent whose attempt produced the frame.
* @param payload.frame - one ordered start, chunk, or end publication.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml
index 814f517c82..0e1510ed88 100644
--- a/packages/core/session/README.i18n.yaml
+++ b/packages/core/session/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
-README.md: 79a71c81632dd2034b93f52f5c4efa49a651c912
-README.zh.md: ec72429a9022f719c99671fe41c7351dccd8c9ba
+README.md: e31dad90392e3661f918d88a041663bd2845a3a3
+README.zh.md: d9a1acfe9ad9214fb149e76bb4e2c28402aa8853
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index 79a71c8163..e31dad9039 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -47,7 +47,7 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
-Surface events (`user/message`, `assistant/message`, `tool/result`) must declare how they join the ordered surface; raw chunks, boundaries, and other log-only events never produce a message.
+Surface events (`user/message`, `assistant/message`, `tool/result`) must declare how they join the ordered surface. An Assistant message embeds the exact compact provider stream that produced it; `assistant/attempt`, boundaries, and other log-only events never produce a message.
### Read the log
@@ -77,7 +77,7 @@ This section explains how the package realizes the behavior above; the observabl
### Design concept
-The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks a `replaceGeneration` that bumps on every committed rewrite. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. The shared [row codec](src/chunk-rows.ts) losslessly converts event sequences to compact rows and back, preserves unrecognized events verbatim, and rejects malformed rows. Persistence backends decide whether to pack writes; bounded history transports can use the same rows while retaining the complete logical interval and exact decoding for consumers that need token boundaries.
+The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks a `replaceGeneration` that bumps on every committed rewrite. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. Each model attempt commits one settlement: `assistant/message` carries the assembled model-visible message plus its compact timed stream, while `assistant/attempt` retains a failed, retried, cancelled, or crash-tail stream without adding model history.
### Request headers
@@ -92,7 +92,6 @@ The package is built on event sourcing: a `Session` is an append-only log of typ
| [`src/surface.ts`](src/surface.ts) | Ordered surface projection, replacement validation, `deriveEventMessage` |
| [`src/request-header.ts`](src/request-header.ts) | `request/header` folding and reconstruction |
| [`dsh-util-values`](../../util/values/README.md) | Shared lossless JSON validation and detached snapshots |
-| [`src/chunk-rows.ts`](src/chunk-rows.ts) | Shared compact-row storage codec for persistence backends |
| [`src/repair.ts`](src/repair.ts) | Cold repair of crash-orphaned logs |
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: seq, turn/step enclosure, tool call/result pairing |
@@ -102,7 +101,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads,
### Derived history
-`deriveMessages()` caches each surface node's projection once and returns a fresh array per call over shared, deep-frozen messages; each of the three surface event types (`user/message`, `assistant/message`, `tool/result`) projects its own message kind — user content verbatim, the assembled assistant message with its provider and model, or a user-role tool result. A surface rewrite rebuilds the projection — there is no raw-log fallback, so the surface is the single source of derived history.
+`deriveMessages()` caches each surface node's projection once and returns a fresh array per call over shared, deep-frozen messages; each of the three surface event types (`user/message`, `assistant/message`, `tool/result`) projects its own message kind — user content verbatim, the assembled assistant message with its provider and model, or a user-role tool result. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only. A surface rewrite rebuilds the projection — there is no raw-log fallback, so the surface is the single source of derived history.
### The request header
@@ -132,7 +131,7 @@ The package-level contract is enough for most consumers; read these when you nee
#### What the model sees
-The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim — identities, roles, sources, and content blocks are the same values established at creation, and projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Chunks, boundaries, usage, and other log-only events add no message.
+The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim — identities, roles, sources, and content blocks are the same values established at creation, and projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message.
#### Token effect
diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md
index ec72429a90..d9a1acfe9a 100644
--- a/packages/core/session/README.zh.md
+++ b/packages/core/session/README.zh.md
@@ -47,7 +47,7 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
-表层事件(`user/message`、`assistant/message`、`tool/result`)必须声明如何进入有序 surface;原始分片、边界与其他仅日志事件从不产生消息。
+表层事件(`user/message`、`assistant/message`、`tool/result`)必须声明如何进入有序 surface。Assistant message 会嵌入产生它的精确紧凑 provider stream;`assistant/attempt`、边界与其他仅日志事件从不产生消息。
### 读取日志
@@ -77,7 +77,7 @@ session.deriveMessages() // the derived model history
### 设计理念
-该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,并跟踪每次已提交重写都会递增的 `replaceGeneration`。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。共享的[行编解码器](src/chunk-rows.ts)在事件序列与紧凑行之间无损转换,逐字保留无法识别的事件,并拒绝形态错误的行。持久化后端决定是否打包写入;有界历史传输可以使用同一种行,同时保留完整逻辑区间,并为需要 token 边界的消费方提供精确解码。
+该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,并跟踪每次已提交重写都会递增的 `replaceGeneration`。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。每个模型 attempt 提交一个 settlement:`assistant/message` 携带组装后的模型可见 message 及其紧凑带时间 stream,`assistant/attempt` 则保留失败、重试、取消或崩溃尾部 stream,且不添加模型历史。
### 请求 header
@@ -92,7 +92,6 @@ session.deriveMessages() // the derived model history
| [`src/surface.ts`](src/surface.ts) | 有序 surface 投影、替换校验、`deriveEventMessage` |
| [`src/request-header.ts`](src/request-header.ts) | `request/header` 折叠与重建 |
| [`dsh-util-values`](../../util/values/README.zh.md) | 共享无损 JSON 校验与分离式快照 |
-| [`src/chunk-rows.ts`](src/chunk-rows.ts) | 供持久化后端使用的共享紧凑行存储编解码器 |
| [`src/repair.ts`](src/repair.ts) | 崩溃遗留日志的冷修复 |
| [`src/invariant.ts`](src/invariant.ts) | 不变式配套:序号、轮次/步骤闭合、工具调用/结果配对 |
@@ -102,7 +101,7 @@ session.deriveMessages() // the derived model history
### 派生历史
-`deriveMessages()` 把每个 surface 节点的投影缓存一次,每次调用都返回共享、深度冻结消息之上的新数组;三种 surface 事件类型(`user/message`、`assistant/message`、`tool/result`)各自投影自己的消息种类——user 内容原样、带提供方与模型的组装 assistant 消息,或 user 角色的工具结果。surface 重写会重建投影——不存在原始日志回退,因此 surface 是派生历史的唯一来源。
+`deriveMessages()` 把每个 surface 节点的投影缓存一次,每次调用都返回共享、深度冻结消息之上的新数组;三种 surface 事件类型(`user/message`、`assistant/message`、`tool/result`)各自投影自己的消息种类——user 内容原样、带提供方与模型的组装 assistant 消息,或 user 角色的工具结果。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留重放和诊断数据。surface 重写会重建投影——不存在原始日志回退,因此 surface 是派生历史的唯一来源。
### 请求头
@@ -132,7 +131,7 @@ session.deriveMessages() // the derived model history
#### 模型看到什么
-模型会原样接收 `user/message`、`assistant/message` 与 `tool/result` surface 条目中的完整消息——标识、角色、来源与内容块都与创建时确定的值相同,投影从不生成标识。直接提示词与注入上下文仍是彼此独立的 `user/message` 事件,各事件的来源会保留其出处。分片、边界、用量与其他仅日志事件不会添加消息。
+模型会原样接收 `user/message`、`assistant/message` 与 `tool/result` surface 条目中的完整消息——标识、角色、来源与内容块都与创建时确定的值相同,投影从不生成标识。直接提示词与注入上下文仍是彼此独立的 `user/message` 事件,各事件的来源会保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不会添加消息。
#### Token 影响
diff --git a/packages/core/session/package.json b/packages/core/session/package.json
index 3e4be78030..904d90dd93 100644
--- a/packages/core/session/package.json
+++ b/packages/core/session/package.json
@@ -26,10 +26,6 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
- "./chunk-rows": {
- "types": "./lib/types/chunk-rows.d.ts",
- "default": "./lib/types/chunk-rows.js"
- },
"./src/*": "./src/*",
"./package.json": "./package.json",
"./surface": {
diff --git a/packages/core/session/src/chunk-rows.ts b/packages/core/session/src/chunk-rows.ts
deleted file mode 100644
index 8289b59b5a..0000000000
--- a/packages/core/session/src/chunk-rows.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-/**
- * Lossless row packing for `assistant/chunk` delta runs. Providers stream
- * token-sized deltas, so a log stores hundreds of near-identical event lines
- * whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek
- * session). This module packs each run of consecutive same-block delta chunks
- * into ONE storage row — `text-chunks`, `reasoning-chunks`, or
- * `tool-call-chunks` — and expands rows back to the exact original events.
- *
- * Packed rows are an encoding vocabulary, NOT session events: they never enter
- * `Session.snapshotEvents()`, have no `SessionEventMap` entry, and use bare (slash-less)
- * type tags so a reader cannot confuse them with the event taxonomy
- * (precedent: the JSONL header line's `session` tag). Persistence and bounded
- * history transport both use the codec. The encoder whitelists exact shapes —
- * anything it does not fully recognize stays verbatim, so unknown fields or
- * future chunk variants lose compression, never data. The decoder validates
- * before expanding and fails loud on a malformed row-tagged value instead of
- * silently dropping a whole run.
- *
- * @module @deepseek-ai/dsh-session/chunk-rows
- */
-
-import { brandString } from '@deepseek-ai/dsh-brand'
-import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
-import type { StreamChunk } from '@deepseek-ai/dsh-llm'
-import { SessionSeq } from './types.ts'
-import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts'
-
-/** The chunk kinds that may pack; block boundaries, usage, and finish chunks always stay one event per line. */
-type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
-
-/** A run member: an `assistant/chunk` event whose exact shape the encoder whitelisted. */
-type DeltaEvent = SessionEvent<'assistant/chunk'>
-
-/**
- * Fields shared by every packed run: placement, block correlation, and member
- * timestamps as gaps. Member `k` reconstructs as seq `seq0 + k` and time
- * `time0` plus the first `k` gaps; a gap may be negative when the wall clock
- * stepped backwards between events.
- */
-interface RunDataBase {
- turn: number
- step: number
- /** The stream block index every member shares. */
- index: number
- /** Epoch-ms gaps between consecutive members; length is one less than the member count. */
- dt: number[]
-}
-
-/** Payload of a `text-chunks`/`reasoning-chunks` row: one entry per member, never joined — token boundaries are data. */
-interface TextRunData extends RunDataBase {
- texts: string[]
-}
-
-/** Payload of a `tool-call-chunks` row: the run-constant call identity plus each member's raw arguments fragment. */
-interface ToolCallRunData extends RunDataBase {
- id: ToolCallId
- /** Present iff every member carried it, with one uniform value (a mixed run never packs). */
- name?: string
- args: string[]
-}
-
-/**
- * A packed run of consecutive delta chunk events, discriminated on `type`.
- * `seq0`/`time0` anchor the first member; text and reasoning rows share the
- * {@link TextRunData} payload, tool-call rows carry {@link ToolCallRunData}.
- */
-export type ChunkRow =
- | { type: 'text-chunks'; seq0: SessionSeqType; time0: number; data: TextRunData }
- | { type: 'reasoning-chunks'; seq0: SessionSeqType; time0: number; data: TextRunData }
- | { type: 'tool-call-chunks'; seq0: SessionSeqType; time0: number; data: ToolCallRunData }
-
-/** One durable log line's JSON value: a session event verbatim, or a packed chunk row. */
-export type StorageRecord = SessionEvent | ChunkRow
-
-/**
- * Test whether an encoded record is a packed chunk row rather than a Session event.
- * @param record - one persistence or bounded-history encoding record.
- * @returns Whether the record is a packed chunk row.
- */
-export function isChunkRow(record: StorageRecord): record is ChunkRow {
- return record.type === 'text-chunks'
- || record.type === 'reasoning-chunks'
- || record.type === 'tool-call-chunks'
-}
-
-/**
- * Number of logical Session events represented by one packed row.
- * @param row - validated or encoder-produced packed row.
- * @returns Count of consecutive chunk events in the row.
- */
-export function chunkRowLength(row: ChunkRow): number {
- return row.type === 'tool-call-chunks' ? row.data.args.length : row.data.texts.length
-}
-
-/**
- * Minimum members before a run packs. Below it a row's envelope rivals the
- * event lines it replaces. A format constant, not a tunable: both layouts
- * decode identically, so changing it never invalidates stored logs.
- */
-const MIN_RUN = 3
-
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null
-}
-
-/** Exact-key check: `value` has every key in `keys` and nothing else. */
-function hasExactKeys(value: object, keys: readonly string[]): boolean {
- return Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k))
-}
-
-/**
- * Classify an event for packing: its delta kind when the ENTIRE shape
- * (envelope, data, chunk — exact keys, primitive types, integer seq/time) is
- * whitelisted, else `undefined` (store verbatim). Inputs come from live typed
- * appends AND parsed fixture files, so the checks are structural, not
- * type-trusted. Integer times keep gap encoding exact: a fractional time would
- * reconstruct through float subtraction/addition, which need not round-trip.
- */
-function classify(event: SessionEvent): DeltaKind | undefined {
- if (event.type !== 'assistant/chunk') return undefined
- if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined
- if (!Number.isSafeInteger(event.seq) || event.seq < 0 || Object.is(event.seq, -0)
- || !Number.isSafeInteger(event.time)) return undefined
- const data: unknown = event.data
- if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined
- if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined
- const chunk = data.chunk
- if (!isRecord(chunk) || typeof chunk.index !== 'number') return undefined
- switch (chunk.type) {
- case 'text-delta':
- case 'reasoning-delta':
- return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string'
- ? chunk.type
- : undefined
- case 'tool-call-delta': {
- const shapeOk = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta'])
- || (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta']) && typeof chunk.name === 'string')
- return shapeOk && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string'
- ? chunk.type
- : undefined
- }
- // Whitelist fall-through over parsed data: block-start/end, usage, finish,
- // and any future chunk variant stay one event per line.
- default:
- return undefined
- }
-}
-
-/** The tool-call fields of a whitelisted delta chunk (only after {@link classify} returned `'tool-call-delta'`). */
-function toolCallOf(event: DeltaEvent): { id: string; name?: string } {
- return event.data.chunk as { id: string; name?: string }
-}
-
-/** The block index of a whitelisted delta chunk (not every {@link StreamChunk} variant carries one). */
-function indexOf(event: DeltaEvent): number {
- return (event.data.chunk as { index: number }).index
-}
-
-/** Whether `next` extends a run ending in `prev` (same kind already checked by the caller). */
-function continues(prev: DeltaEvent, next: DeltaEvent, kind: DeltaKind): boolean {
- if (next.seq !== prev.seq + 1) return false
- // Two safe-integer times can sit further apart than a double subtracts
- // exactly (2^53-1 and its negation differ by ~2^54); a rounded gap would
- // decode to a different timestamp. The check is exact in both directions: a
- // true gap within safe range subtracts without rounding and passes, while a
- // true gap beyond it rounds to a value that is itself beyond and fails.
- if (!Number.isSafeInteger(next.time - prev.time)) return false
- if (next.data.turn !== prev.data.turn || next.data.step !== prev.data.step) return false
- if (indexOf(next) !== indexOf(prev)) return false
- if (kind !== 'tool-call-delta') return true
- const a = toolCallOf(prev)
- const b = toolCallOf(next)
- // `name` must match in presence AND value — a mixed run is not representable.
- return a.id === b.id && Object.hasOwn(a, 'name') === Object.hasOwn(b, 'name') && a.name === b.name
-}
-
-/** Build the row for a completed run (`run.length >= MIN_RUN`, uniform per {@link continues}). */
-function buildRow(kind: DeltaKind, run: readonly DeltaEvent[]): ChunkRow {
- const first = run[0] as DeltaEvent
- const base = {
- turn: first.data.turn,
- step: first.data.step,
- index: indexOf(first),
- dt: run.slice(1).map((event, i) => event.time - (run[i] as DeltaEvent).time),
- }
- const envelope = { seq0: first.seq, time0: first.time }
- if (kind === 'tool-call-delta') {
- const call = toolCallOf(first)
- return {
- type: 'tool-call-chunks',
- ...envelope,
- data: {
- ...base,
- id: brandString(call.id),
- ...Object.hasOwn(call, 'name') ? { name: call.name as string } : {},
- args: run.map(event => (event.data.chunk as { argumentsDelta: string }).argumentsDelta),
- },
- }
- }
- const data = { ...base, texts: run.map(event => (event.data.chunk as { text: string }).text) }
- return kind === 'text-delta'
- ? { type: 'text-chunks', ...envelope, data }
- : { type: 'reasoning-chunks', ...envelope, data }
-}
-
-/**
- * Pack an event batch for storage: each run of at least {@link MIN_RUN}
- * consecutive whitelisted same-kind, same-block delta chunk events becomes one
- * {@link ChunkRow}; every other event passes through verbatim, in order.
- * Pure and stateless — safe over any array, including a batch whose runs were
- * split by flush boundaries (the split runs simply pack per batch).
- *
- * @param events - the batch to encode, in log order.
- * @returns the storage records to write, one JSONL line each.
- */
-export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] {
- const out: StorageRecord[] = []
- let kind: DeltaKind | undefined
- let run: DeltaEvent[] = []
- const flush = (): void => {
- if (kind !== undefined && run.length >= MIN_RUN) out.push(buildRow(kind, run))
- else out.push(...run)
- kind = undefined
- run = []
- }
- for (const event of events) {
- const k = classify(event)
- if (k === undefined) {
- flush()
- out.push(event)
- continue
- }
- const delta = event as DeltaEvent
- const last = run[run.length - 1]
- if (k === kind && last !== undefined && continues(last, delta, k)) {
- run.push(delta)
- continue
- }
- flush()
- kind = k
- run = [delta]
- }
- flush()
- return out
-}
-
-/** Throw the uniform malformed-row diagnostic. */
-function malformed(tag: string, why: string): never {
- throw new Error(`malformed ${tag} storage row: ${why}`)
-}
-
-/** Validate the shared run-data fields and the payload/dt arity; returns the member payload. */
-function validateRunData(tag: string, data: Record, payloadKey: 'texts' | 'args'): string[] {
- if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') {
- malformed(tag, 'turn/step/index must be numbers')
- }
- const payload = data[payloadKey]
- if (!Array.isArray(payload) || payload.length === 0 || payload.some(entry => typeof entry !== 'string')) {
- malformed(tag, `${payloadKey} must be a non-empty string array`)
- }
- const dt = data.dt
- if (!Array.isArray(dt) || dt.some(gap => !Number.isSafeInteger(gap))) {
- malformed(tag, 'dt must be an array of safe integers')
- }
- if (dt.length !== payload.length - 1) {
- malformed(tag, `dt length ${dt.length} does not match ${payload.length} members`)
- }
- return payload as string[]
-}
-
-/** Validate a row-tagged parsed value's envelope and data, throwing on any malformation. */
-function validateRow(value: Record, tag: ChunkRow['type']): ChunkRow {
- if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) {
- malformed(tag, 'envelope must be exactly {type, seq0, time0, data}')
- }
- if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0 || Object.is(value.seq0, -0)) {
- malformed(tag, 'seq0 must be a non-negative safe integer')
- }
- if (!Number.isSafeInteger(value.time0)) {
- malformed(tag, 'time0 must be a safe integer')
- }
- const data = value.data
- if (!isRecord(data)) malformed(tag, 'data must be an object')
- let payload: string[]
- if (tag === 'tool-call-chunks') {
- const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args'])
- if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) {
- malformed(tag, 'data must be exactly {turn, step, index, id, name?, dt, args}')
- }
- if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) {
- malformed(tag, 'id (and name when present) must be strings')
- }
- payload = validateRunData(tag, data, 'args')
- } else {
- if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) {
- malformed(tag, 'data must be exactly {turn, step, index, dt, texts}')
- }
- payload = validateRunData(tag, data, 'texts')
- }
- // Reconstruction bounds. The encoder only packs runs whose member seqs and
- // times are all safe integers, so a running value that leaves safe range is
- // outside any encoder's image: float arithmetic would round it to a
- // different number than exact arithmetic, a silent corruption. Within safe
- // range every step is exact, so the first departure is always caught.
- if (payload.length - 1 > Number.MAX_SAFE_INTEGER - (value.seq0 as number)) {
- malformed(tag, 'member seqs must stay safe integers')
- }
- let time = value.time0 as number
- for (const gap of data.dt as number[]) {
- time += gap
- if (!Number.isSafeInteger(time)) malformed(tag, 'member times must stay safe integers')
- }
- SessionSeq(value.seq0 as number)
- return value as unknown as ChunkRow
-}
-
-/** Expand a validated row back into its exact original events, in order. */
-function expandRow(row: ChunkRow): SessionEvent[] {
- const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts
- const events: SessionEvent[] = []
- let time = row.time0
- for (let k = 0; k < members.length; k++) {
- if (k > 0) time += row.data.dt[k - 1] as number
- let chunk: StreamChunk
- switch (row.type) {
- case 'text-chunks':
- chunk = { type: 'text-delta', index: row.data.index, text: members[k] as string }
- break
- case 'reasoning-chunks':
- chunk = { type: 'reasoning-delta', index: row.data.index, text: members[k] as string }
- break
- case 'tool-call-chunks':
- chunk = {
- type: 'tool-call-delta',
- index: row.data.index,
- id: row.data.id,
- ...Object.hasOwn(row.data, 'name') ? { name: row.data.name as string } : {},
- argumentsDelta: members[k] as string,
- }
- break
- /* v8 ignore next 4 -- validateRow only returns the three row tags */
- default: {
- const unreachable: never = row
- throw new Error(`chunk-rows received unsupported row ${String(unreachable)}`)
- }
- }
- events.push({
- type: 'assistant/chunk',
- seq: SessionSeq(row.seq0 + k),
- time,
- data: { turn: row.data.turn, step: row.data.step, chunk },
- })
- }
- return events
-}
-
-/**
- * Decode one parsed JSONL line value into the session event(s) it stores.
- * Chunk-row-tagged values validate and expand (a malformed row throws — it is
- * corrupt storage, and treating it as an event would silently drop a whole
- * run); every other value passes through as a single event after admitting a
- * numeric `seq` through the Session-sequence constructor.
- *
- * @param value - one line's `JSON.parse` result.
- * @returns the stored events, in log order.
- */
-export function decodeStorageRecord(value: unknown): SessionEvent[] {
- if (!isRecord(value)) return [value as SessionEvent]
- const tag = value.type
- if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') {
- if (typeof value.seq === 'number') SessionSeq(value.seq)
- return [value as unknown as SessionEvent]
- }
- return expandRow(validateRow(value, tag))
-}
diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts
index ee98a0a680..9aa2225454 100644
--- a/packages/core/session/src/index.ts
+++ b/packages/core/session/src/index.ts
@@ -25,8 +25,6 @@ export { SessionPreparation } from './preparation.ts'
export type { SessionPreparationOptions } from './preparation.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
-export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
-export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
@@ -559,12 +557,16 @@ export class Session {
if (inheritedEventCount > this.log.length) {
throw new Error('session inherited event count exceeds its event log')
}
+ if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length) {
+ throw new Error('seeded session constructor seed must equal its inherited prefix')
+ }
this.inheritedEventCount = inheritedEventCount
- // Appended here so the marker is already in `events` when a backend
- // captures the creation seed: no load-time write. Re-marking is skipped
- // because a cold session is resumed on first touch, so repeatedly opening
- // one must not grow its log per open.
- if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
+ // A fresh seeded child always owns one tagged marker at its inherited cut,
+ // even when the copied prefix already ends in an ancestor marker. Restore
+ // retains that durable marker and appends only the ordinary resume marker.
+ if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded) {
+ this.append('session/end-seed', { inherited: true })
+ } else if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}
@@ -639,7 +641,8 @@ export class Session {
* declare how it joins the surface, the sole source of derived model
* history) and
* rejected by the compiler for non-surface types like `turn/start` or
- * `assistant/chunk`.
+ * `assistant/attempt`. Assistant messages embed their exact provider
+ * stream and cannot cite top-level source events.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input.
@@ -660,7 +663,7 @@ export class Session {
append(
type: T,
data: SessionEventMap[T],
- ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
+ ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
): SessionEvent {
const surfaceOpts: SurfaceIntent | undefined = opts[0]
const surfaceMetadata = {
diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts
index e479835d3f..3242691324 100644
--- a/packages/core/session/src/invariant.ts
+++ b/packages/core/session/src/invariant.ts
@@ -111,8 +111,8 @@ function validateEvent(
nextStep += 1
break
}
- case 'assistant/chunk': {
- requireOpenStep(trace, 'assistant/chunk', event.data.turn, event.data.step, fail)
+ case 'assistant/attempt': {
+ requireOpenStep(trace, 'assistant/attempt', event.data.turn, event.data.step, fail)
break
}
case 'assistant/message': {
diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts
index 774e1a55cb..6fc390220a 100644
--- a/packages/core/session/src/known-event-types.ts
+++ b/packages/core/session/src/known-event-types.ts
@@ -25,7 +25,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([
'approval/asked',
'approval/decided',
'approval/policy',
- 'assistant/chunk',
+ 'assistant/attempt',
'assistant/message',
'command/done',
'command/run',
diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts
index bfb331eb22..1cec97e3cd 100644
--- a/packages/core/session/src/surface.ts
+++ b/packages/core/session/src/surface.ts
@@ -76,7 +76,7 @@ export function isReplacementSurfaceEvent(
/**
* Project a single event into the LLM message it derives to, or null when it
- * produces none — a non-surface event (chunk, boundary, log-only record) or an
+ * produces none — a non-surface event (attempt, boundary, log-only record) or an
* empty-content assistant/message (which exists only to host usage). This is
* THE per-node projection rule: `Session.deriveMessages` folds it over the
* live surface, external reconstructors and pure projections fold the same
@@ -89,7 +89,7 @@ export function isReplacementSurfaceEvent(
*/
export function deriveEventMessage(event: SessionEvent): Message | null {
// Intentionally non-exhaustive: only message-producing events derive
- // history; turn/step boundaries, chunks, usage, and errors are trace/replay
+ // history; turn/step boundaries, failed attempts, and errors are trace/replay
// data.
switch (event.type) {
// Ordinary prompts and injected context project in user role: the event's
@@ -114,7 +114,7 @@ export function deriveEventMessage(event: SessionEvent): Message | null {
return event.data.message
}
default:
- // A non-surface event (boundary, chunk, log-only record) projects to
+ // A non-surface event (boundary, attempt, log-only record) projects to
// no message. Merge-extensible union: no assertNever here.
return null
}
@@ -223,13 +223,16 @@ function assertProvenance(
shadowedSeqs: readonly SessionSeq[],
): void {
const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
+ if (event.type === 'assistant/message' && raw !== undefined) {
+ throw new Error('assistant/message embeds its source stream and cannot carry sourceEventSeqs')
+ }
const sources = new Set()
if (raw !== undefined) {
if (!Array.isArray(raw)) {
throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
}
- if (raw.length === 0 && event.type !== 'assistant/message') {
- throw new Error('sourceEventSeqs must not be empty except on assistant/message')
+ if (raw.length === 0) {
+ throw new Error('sourceEventSeqs must not be empty')
}
let nonEarlierSource: SessionSeq | undefined
for (const source of raw) {
diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts
index 9ecc3f9ff3..7b6ca4e814 100644
--- a/packages/core/session/src/types.ts
+++ b/packages/core/session/src/types.ts
@@ -1,11 +1,11 @@
import { brandNumber, brandString, type Branded, type BrandedNumber } from '@deepseek-ai/dsh-brand'
import type {
AssistantMessage,
+ AssistantStreamRecord,
ToolCallId,
LlmCallConfig,
LlmCallConfigAdapterDefaults,
LlmFailure,
- StreamChunk,
TokenUsage,
ToolResultMessage,
ToolSchema,
@@ -83,7 +83,7 @@ export type OptionalSessionSeq = SessionSeq | null
* immutable prior-generation, and current fast-path rules are recorded in
* `.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md`.
*/
-export const SESSION_FORMAT_VERSION = 1
+export const SESSION_FORMAT_VERSION = 2
/**
* Immutable validated storage metadata, kept outside the conversation event log.
@@ -137,7 +137,8 @@ export interface CreateSessionOptions {
readonly seed?: readonly SessionEvent[]
/**
* Exact fork-inherited prefix length when `meta.isSeeded` is true. A
- * constructor seed may also contain child-owned setup events after this cut.
+ * In v2 the constructor seed is exactly this inherited prefix; the constructor
+ * appends the child-owned tagged marker at the cut.
*/
readonly inheritedEventCount?: SessionLogOffset
/**
@@ -251,8 +252,8 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series'
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
- * sequence numbers stay contiguous, including raw chunks, so persistence can
- * store the canonical log verbatim.
+ * sequence numbers stay contiguous. Assistant attempt events embed their exact
+ * compact raw streams so persistence stores one durable settlement per attempt.
*/
export interface SessionEventMap {
/**
@@ -283,8 +284,6 @@ export interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
- /** Raw stream chunk — token-level replay fidelity. */
- 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
* Assembled assistant message for one step (derived history uses this).
* Carries the step's `usage` when the adapter reported token accounting, so
@@ -295,7 +294,21 @@ export interface SessionEventMap {
* marker distinguishes that prefix without re-deriving interruption from turn
* boundaries. An aborted turn with no such event streamed no visible content.
*/
- 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
+ 'assistant/message': {
+ turn: number
+ step: number
+ message: AssistantMessage
+ /** Exact timed model stream, compacted without joining delta boundaries. */
+ stream: AssistantStreamRecord[]
+ usage?: TokenUsage
+ interrupted?: true
+ }
+ /**
+ * One model attempt that committed no surface message. The embedded stream
+ * preserves failed, retried, cancelled, or crash-tail output without
+ * fabricating model-visible history.
+ */
+ 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
@@ -339,12 +352,12 @@ export interface SessionEventMap {
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
- * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
- * carry the meaning.
+ * {@link Session.firstLiveSeq}.
*
- * Locate the LAST one in stored history. A seed already ending in one is not
- * re-marked, so reopening an untouched session does not grow its log per
- * pickup and the event need not be at the current `firstLiveSeq`.
+ * A fresh fork child owns one `{ inherited: true }` marker at its exact
+ * inherited-prefix cut, even when that prefix ends in an ancestor marker.
+ * The last tagged marker is the current Session's cut; untagged markers keep
+ * ordinary restore and replay lifecycle boundaries.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
@@ -357,7 +370,7 @@ export interface SessionEventMap {
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
- 'session/end-seed': Record
+ 'session/end-seed': { inherited?: true }
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
@@ -366,7 +379,8 @@ export type SessionEventType = keyof SessionEventMap
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these
- * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
+ * event types may carry {@link SurfaceOp}; user and tool events may also cite
+ * earlier sources through {@link SessionEvent.sourceEventSeqs}.
*/
export type SurfaceEventType =
| 'user/message'
@@ -405,16 +419,15 @@ export type SurfaceOp =
* Surface placement and cited source-event seqs for {@link Session.append}. Required on
* message-producing events and forbidden on log-only events.
*/
-export interface SurfaceIntent {
+export type SurfaceIntent = {
surfaceOp: SurfaceOp
- /**
- * Complete set of known source-event seqs. `assistant/message` may use a
- * present empty array for a known empty provider stream; when the field is
- * absent, the event does not record which earlier events produced the message.
- * Other surface events require a non-empty set when this field is present.
- */
+} & (T extends 'assistant/message' ? {
+ /** V2 Assistant messages embed their provider stream instead of citing source events. */
+ sourceEventSeqs?: never
+} : {
+ /** Complete non-empty set of known earlier source-event seqs. */
sourceEventSeqs?: SessionSeq[]
-}
+})
/**
* One immutable entry in the session log.
@@ -425,7 +438,7 @@ export interface SurfaceIntent {
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`).
- * Non-surface events (boundary markers, chunks, usage, errors) never carry
+ * Non-surface events (boundary markers, attempts, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.
*/
@@ -450,12 +463,9 @@ export type SessionEvent = {
ignorable?: true
} & (K extends SurfaceEventType ? {
/**
- * Seq numbers of earlier events that this event cites as sources
- * (e.g. the `assistant/chunk` seqs that built an `assistant/message`,
- * or the surface nodes shadowed by a compaction replace node). An
- * `assistant/message` may carry a present empty array for a known empty
- * provider stream; when the field is absent, the event does not record which
- * earlier events produced the message.
+ * Seq numbers of earlier events that this event cites as sources, such as
+ * the surface nodes shadowed by a compaction replacement. A v2
+ * `assistant/message` embeds its provider stream and cannot carry this field.
*/
sourceEventSeqs?: SessionSeq[]
/** How this event entered the surface; absent for non-surface events. */
diff --git a/packages/core/session/tests/chunk-rows.spec.ts b/packages/core/session/tests/chunk-rows.spec.ts
deleted file mode 100644
index b18046914b..0000000000
--- a/packages/core/session/tests/chunk-rows.spec.ts
+++ /dev/null
@@ -1,247 +0,0 @@
-/**
- * Chunk-row codec tests: pack/expand round-trip losslessness (example-based and
- * property-based), run-boundary rules, whitelist fall-through, and decoder
- * validation failures.
- */
-
-import { describe, expect, it } from 'vitest'
-import fc from 'fast-check'
-import { ToolCallId } from '@deepseek-ai/dsh-llm'
-import type { StreamChunk } from '@deepseek-ai/dsh-llm'
-import { decodeStorageRecord, packChunkRuns, SessionSeq } from '@deepseek-ai/dsh-session'
-import { chunkRowLength, isChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
-import type { ChunkRow, SessionEvent, StorageRecord } from '@deepseek-ai/dsh-session'
-
-/** Build an `assistant/chunk` event with the exact live-append shape. */
-function chunkEvent(seq: SessionSeq, time: number, chunk: StreamChunk, turn = 1, step = 1): SessionEvent {
- return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk } }
-}
-
-/** Sequential delta events (contiguous seqs, fixed 10ms gaps) of one kind. */
-function deltaRun(kind: 'text-delta' | 'reasoning-delta', count: number, seq0 = 0, index = 0): SessionEvent[] {
- return Array.from({ length: count }, (_, k) =>
- chunkEvent(SessionSeq(seq0 + k), 1000 + 10 * k, { type: kind, index, text: `t${k}` }))
-}
-
-/** Decode a packed record list back to a flat event list. */
-function decodeAll(records: readonly StorageRecord[]): SessionEvent[] {
- return records.flatMap(record => decodeStorageRecord(JSON.parse(JSON.stringify(record))))
-}
-
-describe('packChunkRuns', () => {
- it('packs a text-delta run into one text-chunks row and round-trips it', () => {
- const events = deltaRun('text-delta', 5)
- const packed = packChunkRuns(events)
- expect(packed).toHaveLength(1)
- const row = packed[0] as ChunkRow
- expect(row.type).toBe('text-chunks')
- expect(row.seq0).toBe(0)
- expect(row.time0).toBe(1000)
- expect(row.data).toMatchObject({ turn: 1, step: 1, index: 0, dt: [10, 10, 10, 10], texts: ['t0', 't1', 't2', 't3', 't4'] })
- expect(isChunkRow(row)).toBe(true)
- expect(chunkRowLength(row)).toBe(5)
- expect(isChunkRow(events[0] as SessionEvent)).toBe(false)
- expect(decodeAll(packed)).toStrictEqual(events)
- })
-
- it('packs reasoning and tool-call runs under their own tags', () => {
- const reasoning = deltaRun('reasoning-delta', 3)
- const toolCall = [4, 5, 6].map(seq =>
- chunkEvent(SessionSeq(seq), 1000 + seq, { type: 'tool-call-delta', index: 1, id: ToolCallId('c1'), name: 'write', argumentsDelta: `a${seq}` }))
- const packed = packChunkRuns([...reasoning, ...toolCall])
- expect(packed.map(r => (r as ChunkRow).type)).toStrictEqual(['reasoning-chunks', 'tool-call-chunks'])
- const row = packed[1] as ChunkRow & { type: 'tool-call-chunks' }
- expect(row.data).toMatchObject({ id: 'c1', name: 'write', args: ['a4', 'a5', 'a6'] })
- expect(chunkRowLength(row)).toBe(3)
- expect(decodeAll(packed)).toStrictEqual([...reasoning, ...toolCall])
- })
-
- it('packs a name-less tool-call run and round-trips field absence', () => {
- const events = [0, 1, 2].map(seq =>
- chunkEvent(SessionSeq(seq), 1000, { type: 'tool-call-delta', index: 0, id: ToolCallId('c1'), argumentsDelta: `a${seq}` }))
- const packed = packChunkRuns(events)
- expect(packed).toHaveLength(1)
- expect(Object.hasOwn((packed[0] as ChunkRow).data, 'name')).toBe(false)
- const decoded = decodeAll(packed)
- expect(decoded).toStrictEqual(events)
- expect(decoded.every(e => !Object.hasOwn((e.data as { chunk: object }).chunk, 'name'))).toBe(true)
- })
-
- it('leaves runs shorter than three events verbatim', () => {
- const events = deltaRun('text-delta', 2)
- expect(packChunkRuns(events)).toStrictEqual(events)
- })
-
- it('leaves non-delta chunks and non-chunk events verbatim between runs', () => {
- const events: SessionEvent[] = [
- chunkEvent(SessionSeq(0), 1000, { type: 'block-start', index: 0, blockType: 'text' }),
- ...deltaRun('text-delta', 3, 1),
- chunkEvent(SessionSeq(4), 1040, { type: 'block-end', index: 0, block: { type: 'text', text: 't0t1t2' } }),
- { type: 'step/end', seq: SessionSeq(5), time: 1050, data: { turn: 1, step: 1 } },
- ]
- const packed = packChunkRuns(events)
- expect(packed).toHaveLength(4)
- expect((packed[1] as ChunkRow).type).toBe('text-chunks')
- expect(decodeAll(packed)).toStrictEqual(events)
- })
-
- it.each([
- ['a seq gap', deltaRun('text-delta', 3).map((e, k) => ({ ...e, seq: k === 2 ? SessionSeq(9) : e.seq }))],
- ['a kind switch', [...deltaRun('text-delta', 2), ...deltaRun('reasoning-delta', 1, 2)]],
- ['a block-index switch', [...deltaRun('text-delta', 2), ...deltaRun('text-delta', 1, 2, 7)]],
- ['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(SessionSeq(e.seq), e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)],
- ])('breaks a run on %s (both halves too short to pack)', (_label, events) => {
- expect(packChunkRuns(events)).toStrictEqual(events)
- })
-
- it('breaks a tool-call run on call-id or name change', () => {
- const call = (seq: SessionSeq, id: string, name?: string): SessionEvent =>
- chunkEvent(seq, 1000, { type: 'tool-call-delta', index: 0, id: ToolCallId(id), ...name !== undefined ? { name } : {}, argumentsDelta: 'a' })
- const idSwitch = [call(SessionSeq(0), 'c1', 'w'), call(SessionSeq(1), 'c1', 'w'), call(SessionSeq(2), 'c2', 'w')]
- expect(packChunkRuns(idSwitch)).toStrictEqual(idSwitch)
- const namePresence = [call(SessionSeq(0), 'c1', 'w'), call(SessionSeq(1), 'c1', 'w'), call(SessionSeq(2), 'c1')]
- expect(packChunkRuns(namePresence)).toStrictEqual(namePresence)
- })
-
- it('stores an off-whitelist delta verbatim (extra field, bad type, fractional time)', () => {
- const extraField = { ...chunkEvent(SessionSeq(0), 1000, { type: 'text-delta', index: 0, text: 'x' }), surfaceOp: 'append' }
- const badText = chunkEvent(SessionSeq(1), 1001, { type: 'text-delta', index: 0, text: 7 as unknown as string })
- const fractionalTime = chunkEvent(SessionSeq(2), 1001.5, { type: 'text-delta', index: 0, text: 'y' })
- const events = [extraField, badText, fractionalTime] as SessionEvent[]
- expect(packChunkRuns(events)).toStrictEqual(events)
- })
-
- it('breaks a run on a time gap beyond safe-integer range (subtraction would round)', () => {
- // Both endpoints are safe integers, but their true difference (~2^54)
- // exceeds exact double range: b - a rounds, so a + (b - a) !== b and a
- // packed row would decode to a different timestamp.
- const a = Number.MIN_SAFE_INTEGER
- const b = Number.MAX_SAFE_INTEGER - 1
- expect(a + (b - a)).not.toBe(b) // the rounding this guard exists for
- const events = [
- chunkEvent(SessionSeq(0), a, { type: 'text-delta', index: 0, text: 'x' }),
- chunkEvent(SessionSeq(1), b, { type: 'text-delta', index: 0, text: 'y' }),
- chunkEvent(SessionSeq(2), b + 1, { type: 'text-delta', index: 0, text: 'z' }),
- ]
- expect(packChunkRuns(events)).toStrictEqual(events) // split at the gap; halves too short
- expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
- })
-
- it('stores a delta with an off-whitelist data envelope verbatim (parsed-fixture shapes)', () => {
- const mk = (seq: SessionSeq, data: unknown): SessionEvent =>
- ({ type: 'assistant/chunk', seq, time: 1000, data } as SessionEvent)
- const events = [
- mk(SessionSeq(0), 'not-an-object'),
- mk(SessionSeq(1), { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' }, extra: 1 }),
- mk(SessionSeq(2), { turn: 'x', step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }),
- mk(SessionSeq(3), { turn: 1, step: 1, chunk: 'not-an-object' }),
- mk(SessionSeq(4), { turn: 1, step: 1, chunk: { type: 'text-delta', index: 'x', text: 'a' } }),
- mk(SessionSeq(5), { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 7, argumentsDelta: 'a' } }),
- mk(SessionSeq(6), { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'c', name: 7, argumentsDelta: 'a' } }),
- ]
- expect(packChunkRuns(events)).toStrictEqual(events)
- })
-})
-
-describe('decodeStorageRecord', () => {
- it('passes non-row values through after sequence admission', () => {
- const event = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }
- const decoded = decodeStorageRecord(event)
- expect(decoded).toStrictEqual([event])
- expect(decoded[0]).toBe(event)
- expect(decodeStorageRecord('junk')).toStrictEqual(['junk'])
- expect(decodeStorageRecord(null)).toStrictEqual([null])
- const withoutSeq = { type: 'future/event', data: {} }
- expect(decodeStorageRecord(withoutSeq)).toStrictEqual([withoutSeq])
- expect(() => decodeStorageRecord({ ...event, seq: -0 })).toThrow(/SessionSeq/)
- })
-
- it('reconstructs timestamps through negative dt gaps (clock stepped back)', () => {
- const events = [
- chunkEvent(SessionSeq(0), 1000, { type: 'text-delta', index: 0, text: 'a' }),
- chunkEvent(SessionSeq(1), 990, { type: 'text-delta', index: 0, text: 'b' }),
- chunkEvent(SessionSeq(2), 995, { type: 'text-delta', index: 0, text: 'c' }),
- ]
- expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
- })
-
- it.each([
- ['a non-object data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'x' }],
- ['an envelope with extra keys', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] }, extra: 1 }],
- ['a negative seq0', { type: 'text-chunks', seq0: -1, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
- ['a negative-zero seq0', { type: 'text-chunks', seq0: -0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
- ['a non-finite time0', { type: 'text-chunks', seq0: 0, time0: Infinity, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
- ['a fractional time0', { type: 'text-chunks', seq0: 0, time0: 1.5, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
- ['a data shape mismatch', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: ['a'] } }],
- ['a non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: [7] } }],
- ['an empty member list', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: [] } }],
- ['a dt arity mismatch', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [1, 2], texts: ['a', 'b'] } }],
- ['a non-finite dt gap', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [NaN], texts: ['a', 'b'] } }],
- ['a fractional dt gap', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0.5], texts: ['a', 'b'] } }],
- ['a member seq leaving safe range', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }],
- ['a member time leaving safe range', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1], texts: ['a', 'b'] } }],
- ['a non-numeric turn', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 'x', step: 1, index: 0, dt: [], texts: ['a'] } }],
- ['a tool-call row without id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: ['a'] } }],
- ['a tool-call row with non-string id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 7, dt: [], args: ['a'] } }],
- ['a tool-call row with non-string name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'c', name: 7, dt: [], args: ['a'] } }],
- ])('throws on %s', (_label, row) => {
- expect(() => decodeStorageRecord(row)).toThrow(/malformed .* storage row/)
- })
-})
-
-// --- Property: pack∘decode is the identity over arbitrary event batches ---
-
-const deltaChunkArb: fc.Arbitrary = fc.oneof(
- fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }),
- fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }),
- fc.record({
- type: fc.constant<'tool-call-delta'>('tool-call-delta'),
- index: fc.nat(2),
- id: fc.constantFrom(ToolCallId('c1'), ToolCallId('c2')),
- argumentsDelta: fc.string(),
- }),
- fc.record({
- type: fc.constant<'tool-call-delta'>('tool-call-delta'),
- index: fc.nat(2),
- id: fc.constantFrom(ToolCallId('c1'), ToolCallId('c2')),
- name: fc.constantFrom('write', 'read'),
- argumentsDelta: fc.string(),
- }),
-)
-
-const boundaryChunkArb: fc.Arbitrary = fc.oneof(
- fc.record({ type: fc.constant<'block-start'>('block-start'), index: fc.nat(2), blockType: fc.constant<'text'>('text') }),
- fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }),
-)
-
-/**
- * Batches with contiguous seqs, arbitrary timestamps, mixed chunk kinds and
- * turn/step placement. Times draw from the FULL safe-integer range (not just
- * realistic clocks) so the property exercises the gap-overflow guard: two safe
- * endpoints can differ by more than a double subtracts exactly.
- */
-const batchArb: fc.Arbitrary = fc.array(
- fc.record({
- chunk: fc.oneof({ weight: 4, arbitrary: deltaChunkArb }, { weight: 1, arbitrary: boundaryChunkArb }),
- time: fc.oneof(
- { weight: 4, arbitrary: fc.integer({ min: 995, max: 9000 }) },
- { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
- ),
- turn: fc.nat(1),
- step: fc.nat(1),
- }),
- { maxLength: 40 },
- // JSON round-trip normalizes fast-check's null-prototype records into the
- // plain objects real log events are (the log is JSON), so equality compares
- // values, not prototypes.
-).map(entries => JSON.parse(JSON.stringify(
- entries.map((entry, k) => chunkEvent(SessionSeq(k), entry.time, entry.chunk, entry.turn, entry.step)),
-)) as SessionEvent[])
-
-describe('chunk-row codec properties', () => {
- it('JSON-serialized pack∘decode reproduces every batch exactly', () => {
- fc.assert(fc.property(batchArb, (events) => {
- expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
- }))
- })
-})
diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts
index c2f11e3572..2abd241411 100644
--- a/packages/core/session/tests/derived-cache.spec.ts
+++ b/packages/core/session/tests/derived-cache.spec.ts
@@ -27,6 +27,7 @@ describe('derived-message cache', () => {
expect(session.deriveMessages()).toEqual(scratch(session))
userText(session, 'two')
session.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -39,6 +40,7 @@ describe('derived-message cache', () => {
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
session.append('assistant/message', {
+ stream: [],
turn: 1, step: 2,
message: createMessage({
role: 'assistant',
@@ -118,6 +120,7 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
const boundary = session.append('step/start', { turn: 1, step: 1 })
expect(session.deriveEventMessage(boundary)).toBeNull()
const empty = session.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts
index b5f1c02cad..d67538ded8 100644
--- a/packages/core/session/tests/fork.spec.ts
+++ b/packages/core/session/tests/fork.spec.ts
@@ -254,6 +254,7 @@ describe('SessionStore.fork', () => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -271,6 +272,7 @@ describe('SessionStore.fork', () => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts
index 8f6767411d..4f7d61151f 100644
--- a/packages/core/session/tests/invariant.spec.ts
+++ b/packages/core/session/tests/invariant.spec.ts
@@ -40,8 +40,12 @@ describe('session-log invariants', () => {
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
- session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
+ session.append('assistant/attempt', {
+ turn: 1, step: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['h'] }],
+ })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -181,6 +185,7 @@ describe('session-log invariants', () => {
.toThrow(/while step 1 is still open/)
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
expect(() => nested.append('assistant/message', {
+ stream: [],
turn: 1,
step: 2,
message: createMessage({
@@ -209,10 +214,10 @@ describe('session-log invariants', () => {
it('requires step-scoped stream and tool events to name the open step', async () => {
const chunk = (await setup()).ctx.sessions.create()
chunk.append('turn/start', { turn: 1 })
- expect(() => chunk.append('assistant/chunk', {
+ expect(() => chunk.append('assistant/attempt', {
turn: 1,
step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'x' },
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['x'] }],
})).toThrow(/open is turn 1\/step null/)
const tool = (await setup()).ctx.sessions.create()
@@ -393,10 +398,10 @@ describe('session-log invariants', () => {
session.append('step/start', { turn: 1, step: 1 })
await fiber.dispose()
await ctx.plugin(SessionInvariant)
- expect(() => session.append('assistant/chunk', {
+ expect(() => session.append('assistant/attempt', {
turn: 1,
step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'h' },
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['h'] }],
})).not.toThrow()
expect(() => session.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts
index 4eef9e7283..07bf37ac02 100644
--- a/packages/core/session/tests/properties.spec.ts
+++ b/packages/core/session/tests/properties.spec.ts
@@ -35,6 +35,7 @@ const messageEventArb: fc.Arbitrary = fc.oneof(
data: {
turn: 1,
step: 1,
+ stream: [],
message: createMessage({
role: 'assistant',
content,
@@ -48,6 +49,7 @@ const messageEventArb: fc.Arbitrary = fc.oneof(
data: {
turn: 1,
step: 1,
+ stream: [],
message: createMessage({
role: 'assistant',
content,
@@ -74,7 +76,10 @@ const nonMessageEventArb: fc.Arbitrary = fc.oneof(
fc.constant({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
fc.constant({ type: 'step/start', data: { turn: 1, step: 1 } }),
fc.constant({ type: 'step/end', data: { turn: 1, step: 1 } }),
- fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })),
+ fc.string().map((text): Appendable => ({
+ type: 'assistant/attempt',
+ data: { turn: 1, step: 1, stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: [text] }] },
+ })),
)
const anyEventArb = fc.oneof(messageEventArb, nonMessageEventArb)
diff --git a/packages/core/session/tests/sequence-types.spec.ts b/packages/core/session/tests/sequence-types.spec.ts
index debbf5fe97..d0c6cc6217 100644
--- a/packages/core/session/tests/sequence-types.spec.ts
+++ b/packages/core/session/tests/sequence-types.spec.ts
@@ -82,25 +82,23 @@ describe('Session log positions', () => {
expect(fresh.inheritedEventCount).toBe(0)
})
- it('retains a child-owned constructor-seed suffix after the inherited cut', () => {
+ it('appends child-owned setup after the constructor-owned inherited marker', () => {
const parent = Session.create(SessionId('suffix-parent'))
parent.append('turn/start', { turn: 1 })
parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- const assembled = Session.create(SessionId('assembled-seed'), parent.snapshotEvents())
- assembled.append('request/context', { provider: 'provider', model: 'model' })
const id = SessionId('suffix-child')
- const child = Session.create(id, assembled.snapshotEvents(), {
+ const child = Session.create(id, parent.snapshotEvents(), {
version: SESSION_FORMAT_VERSION,
id,
createdAt: 1,
isSeeded: true,
}, parent.seq)
+ child.append('request/context', { provider: 'provider', model: 'model' })
expect(child.ownEvents().map(event => event.type)).toEqual([
'session/end-seed',
'request/context',
- 'session/end-seed',
])
expect(child.isOwnSeq(SessionSeq(1))).toBe(false)
expect(child.isOwnSeq(SessionSeq(2))).toBe(true)
@@ -137,6 +135,15 @@ describe('Session log positions', () => {
expect(() => Session.create(seededId, [], {
version: SESSION_FORMAT_VERSION, id: seededId, createdAt: 1, isSeeded: true,
}, SessionLogOffset(1))).toThrow(/inherited event count exceeds its event log/)
+
+ const shortCutId = SessionId('seeded-short-cut')
+ const seed = [
+ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
+ { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+ ] as const
+ expect(() => Session.create(shortCutId, seed, {
+ version: SESSION_FORMAT_VERSION, id: shortCutId, createdAt: 1, isSeeded: true,
+ }, SessionLogOffset(1))).toThrow(/constructor seed must equal its inherited prefix/)
})
it.each([-1, 0.5, Number.MAX_SAFE_INTEGER + 1])(
diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts
index 8cf700ced6..db5c0ffcac 100644
--- a/packages/core/session/tests/session.spec.ts
+++ b/packages/core/session/tests/session.spec.ts
@@ -28,8 +28,12 @@ describe('Session', () => {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
- session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
+ session.append('assistant/attempt', {
+ turn: 1, step: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['hi'] }],
+ })
session.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -122,6 +126,7 @@ describe('Session', () => {
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
original.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -1536,18 +1541,10 @@ describe('SessionStore', () => {
}
})
- expect(() => session.append('assistant/message', {
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'replacement' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, {
+ expect(() => session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'replacement' }],
+ source: { kind: 'plugin', plugin: 'test' },
+ }), {
surfaceOp: { op: 'replace', start: SessionSeq(2), end: SessionSeq(2) },
sourceEventSeqs: [SessionSeq(2)],
})).toThrow('reject surface candidate')
diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts
index d89c77d851..8e83a68728 100644
--- a/packages/core/session/tests/surface.spec.ts
+++ b/packages/core/session/tests/surface.spec.ts
@@ -29,6 +29,13 @@ function surfaceOp(value: TestSurfaceOp): SurfaceEvent['surfaceOp'] {
: { op: 'replace', start: SessionSeq(value.start), end: SessionSeq(value.end) }
}
+function replacementMessage(text: string) {
+ return createUserMessage({
+ content: [{ type: 'text', text }],
+ source: { kind: 'plugin', plugin: 'test' },
+ })
+}
+
function sourceSeqs(...values: number[]) {
return values.map(SessionSeq)
}
@@ -41,6 +48,7 @@ function surfaceSession(): Session {
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -116,7 +124,7 @@ describe('foldSurface source-event references', () => {
expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
})
- it('accepts an explicit empty source-event list on an assistant message', () => {
+ it('rejects obsolete source-event provenance on an assistant message', () => {
const event = {
type: 'assistant/message',
seq: SessionSeq(0),
@@ -132,11 +140,12 @@ describe('foldSurface source-event references', () => {
...{ provider: 'mock', model: 'mock' },
},
}),
+ stream: [],
},
surfaceOp: 'append',
sourceEventSeqs: [],
} as SessionEvent
- expect(() => foldSurface([event])).not.toThrow()
+ expect(() => foldSurface([event])).toThrow(/embeds its source stream/)
})
it.each([
@@ -309,28 +318,14 @@ describe('SurfaceManager', () => {
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
- s.append('assistant/message', {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'summary' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }), sourceEventSeqs: sourceSeqs(0) })
- s.append('assistant/message', {
- turn: 1, step: 2,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'summary 2' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: surfaceOp({ op: 'replace', start: 2, end: 1 }), sourceEventSeqs: sourceSeqs(2, 1) })
+ s.append('user/message', replacementMessage('summary'), {
+ surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }),
+ sourceEventSeqs: sourceSeqs(0),
+ })
+ s.append('user/message', replacementMessage('summary 2'), {
+ surfaceOp: surfaceOp({ op: 'replace', start: 2, end: 1 }),
+ sourceEventSeqs: sourceSeqs(2, 1),
+ })
const folded = foldSurface(s.snapshotEvents())
expect(folded.nodes).toEqual(s.surface.nodes)
@@ -350,17 +345,10 @@ describe('SurfaceManager', () => {
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
- s.append('assistant/message', {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'b' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }), sourceEventSeqs: sourceSeqs(0) })
+ s.append('user/message', replacementMessage('b'), {
+ surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }),
+ sourceEventSeqs: sourceSeqs(0),
+ })
expect(s.surface.nodes).toEqual([1])
const manager = s.surface as unknown as { _state: object }
@@ -404,6 +392,7 @@ describe('SurfaceManager', () => {
expect(() => s.append(
'assistant/message',
{
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -509,18 +498,8 @@ describe('SurfaceManager', () => {
it('rebuild with replace operation splices out shadowed nodes', () => {
const s = surfaceSession()
- s.append('assistant/message',
- {
- turn: 2, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'summary' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ s.append('user/message',
+ replacementMessage('summary'),
{ surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 2 }), sourceEventSeqs: sourceSeqs(1, 2) },
)
expect(s.surface.nodes).toEqual([4])
@@ -538,18 +517,8 @@ describe('SurfaceManager', () => {
content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 2
// Replace seq 0 through 1 inclusive: shadow a and b, keep c.
- s.append('assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'summary' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ s.append('user/message',
+ replacementMessage('summary'),
{ surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 1 }), sourceEventSeqs: sourceSeqs(0, 1) },
) // seq 3
expect(s.surface.nodes).toEqual([3, 2])
@@ -564,18 +533,8 @@ describe('SurfaceManager', () => {
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
// Replace only seq 1 (single node).
- s.append('assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'x' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ s.append('user/message',
+ replacementMessage('x'),
{ surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 1 }), sourceEventSeqs: sourceSeqs(1) },
) // seq 2
expect(s.surface.nodes).toEqual([0, 2])
@@ -586,18 +545,8 @@ describe('SurfaceManager', () => {
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
- expect(() => s.append('assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'y' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ expect(() => s.append('user/message',
+ replacementMessage('y'),
{ surfaceOp: surfaceOp({ op: 'replace', start: 5, end: 0 }), sourceEventSeqs: sourceSeqs(0) },
)).toThrow(/surface replace: start seq 5 not found/)
})
@@ -607,18 +556,8 @@ describe('SurfaceManager', () => {
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
- expect(() => s.append('assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'y' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ expect(() => s.append('user/message',
+ replacementMessage('y'),
{ surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 99 }), sourceEventSeqs: sourceSeqs(0) },
)).toThrow(/surface replace: end seq 99 not found/)
})
@@ -632,18 +571,8 @@ describe('SurfaceManager', () => {
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
// start=1, end=0 would be reversed order.
- expect(() => s.append('assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'y' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ expect(() => s.append('user/message',
+ replacementMessage('y'),
{ surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 0 }), sourceEventSeqs: sourceSeqs(1, 0) },
)).toThrow(/start seq 1.*after end seq 0/)
})
@@ -654,17 +583,10 @@ describe('SurfaceManager', () => {
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const sources = sourceSeqs(0)
- s.append('assistant/message', {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'h' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append', sourceEventSeqs: sources })
+ s.append('user/message', replacementMessage('h'), {
+ surfaceOp: 'append',
+ sourceEventSeqs: sources,
+ })
// Mutate caller's array after append.
sources.push(SessionSeq(1))
sources[0] = SessionSeq(99)
@@ -684,18 +606,8 @@ describe('SurfaceManager', () => {
content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 2
// Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
- s.append('assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'x' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ s.append('user/message',
+ replacementMessage('x'),
{ surfaceOp: surfaceOp({ op: 'replace', start: 1, end: 1 }), sourceEventSeqs: sourceSeqs(1) },
) // seq 3
expect(s.surface.nodes).toEqual([0, 3, 2])
@@ -707,17 +619,10 @@ describe('SurfaceManager', () => {
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const op = { op: 'replace' as const, start: SessionSeq(0), end: SessionSeq(0) }
- s.append('assistant/message', {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 's' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: op, sourceEventSeqs: sourceSeqs(0) })
+ s.append('user/message', replacementMessage('s'), {
+ surfaceOp: op,
+ sourceEventSeqs: sourceSeqs(0),
+ })
// Mutate caller's object after append.
op.start = SessionSeq(99)
const logged = s.snapshotEvents()[1]! as SurfaceEvent
@@ -736,15 +641,18 @@ describe('deriveMessages with surface', () => {
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
})
- it('surface path skips non-surface events (chunks, boundaries)', () => {
+ it('surface path skips non-surface events (attempts, boundaries)', () => {
const s = Session.create(SessionId('filter'))
s.append('turn/start', { turn: 1 })
- s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
- s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
+ s.append('assistant/attempt', {
+ turn: 1, step: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['h', 'i'] }],
+ })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -756,7 +664,7 @@ describe('deriveMessages with surface', () => {
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- // Chunks and boundaries are NOT in the surface, so only 2 messages.
+ // Attempts and boundaries are NOT in the surface, so only 2 messages.
expect(s.deriveMessages()).toHaveLength(2)
})
@@ -765,17 +673,10 @@ describe('deriveMessages with surface', () => {
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
- s.append('assistant/message', {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'compacted' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }), sourceEventSeqs: sourceSeqs(0) })
+ s.append('user/message', replacementMessage('compacted'), {
+ surfaceOp: surfaceOp({ op: 'replace', start: 0, end: 0 }),
+ sourceEventSeqs: sourceSeqs(0),
+ })
// Only the compaction node is visible.
const messages = s.deriveMessages()
expect(messages).toHaveLength(1)
@@ -799,22 +700,16 @@ describe('deriveMessages with surface', () => {
})
describe('Session.append surface opts', () => {
- it('records sourceEventSeqs and surfaceOp on the event', () => {
+ it('records sourceEventSeqs and surfaceOp on a source-derived event', () => {
const s = Session.create(SessionId('opts'))
s.append('turn/start', { turn: 1 })
s.append('step/start', { turn: 1, step: 1 })
- const event = s.append('assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'h' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ const event = s.append(
+ 'user/message',
+ createUserMessage({
+ content: [{ type: 'text', text: 'h' }],
+ source: { kind: 'plugin', plugin: 'test' },
+ }),
{ surfaceOp: 'append', sourceEventSeqs: sourceSeqs(0, 1) },
)
expect(event.sourceEventSeqs).toEqual([0, 1])
@@ -832,6 +727,7 @@ describe('Session.append surface opts', () => {
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: SessionSeq(2), time: 3, data: {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -860,6 +756,7 @@ describe('Session.append surface opts', () => {
it('surfaceOp primitives are not cloned (they are immutable)', () => {
const s = Session.create(SessionId('prim'))
const event = s.append('assistant/message', {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -900,7 +797,7 @@ describe('surface type guards', () => {
expect(isSurfaceEligibleType('assistant/message')).toBe(true)
expect(isSurfaceEligibleType('tool/result')).toBe(true)
expect(isSurfaceEligibleType('turn/start')).toBe(false)
- expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
+ expect(isSurfaceEligibleType('assistant/attempt')).toBe(false)
})
it('isSurfaceEvent narrows a fully-formed surface event', () => {
diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts
index fb115719aa..129bd7faed 100644
--- a/packages/experimental/agent-team/tests/persistence.spec.ts
+++ b/packages/experimental/agent-team/tests/persistence.spec.ts
@@ -11,7 +11,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
-import SubagentService, { seedDescriptorTurn, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import TeamService, { TeamId, TeamMessageId } from '../src/index.ts'
@@ -124,17 +124,17 @@ function persistedChild(
childId: SessionId,
message: ReturnType,
) {
- const seed = seedDescriptorTurn(childId, undefined, snapshotSubagentDescriptor({
+ const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'spawn',
label: 'persisted child fixture',
agentProvider: 'mock',
agentModel: 'mock',
- }))
+ })
const child = ctx.sessions.create(childId, {
- seed,
meta: { parentSession: rootId, origin: 'subagent' },
})
+ child.append('subagent/descriptor', descriptor)
child.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts
index 57c62329d3..96f38632fe 100644
--- a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts
+++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.spec.ts
@@ -7,6 +7,7 @@ import {
scanLog,
} from '@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts'
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
import {
buildVfsExampleFiles,
VFS_EXAMPLE_OLDEST_MESSAGE,
@@ -30,10 +31,10 @@ function filesUnder(root: string): string[] {
}
function readSession(id: string): ReturnType {
- return scanLog(readFileSync(
- join(VFS_EXAMPLE_ROOT, 'home/sessions/--dsh-workspace--', id,
- generationLogFilename(SESSION_FORMAT_VERSION, 'none')),
- ))
+ const path = `home/sessions/--dsh-workspace--/${id}/${generationLogFilename(SESSION_FORMAT_VERSION, 'none')}`
+ const generated = buildVfsExampleFiles().get(path)
+ if (generated === undefined) throw new Error(`missing generated VFS example Session ${path}`)
+ return scanLog(Buffer.from(generated))
}
function textOf(event: SessionEvent): string {
@@ -49,14 +50,30 @@ function textOf(event: SessionEvent): string {
describe('WebWorker preview VFS example', () => {
it('matches its deterministic source byte for byte', () => {
const expected = buildVfsExampleFiles()
- expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([...expected.keys()].sort())
+ const generatedV2 = [...expected.keys()]
+ .filter(path => path.endsWith('/session.v2.jsonl'))
+ const retainedV1 = generatedV2.map(path => path.replace(/\.v2\.jsonl$/, '.v1.jsonl'))
+ const deferred = new Set([...generatedV2, 'home/storages/session_projcache.json'])
+ const stable = [...expected.keys()].filter(path => !deferred.has(path))
+ expect(filesUnder(VFS_EXAMPLE_ROOT)).toEqual([
+ ...stable,
+ ...retainedV1,
+ 'home/storages/session_projcache.json',
+ ].sort())
for (const [path, content] of expected) {
+ if (deferred.has(path)) continue
expect(readFileSync(join(VFS_EXAMPLE_ROOT, path), 'utf8'), path).toBe(content)
}
+ for (const path of retainedV1) {
+ const header = JSON.parse(readFileSync(join(VFS_EXAMPLE_ROOT, path), 'utf8').split('\n')[0] as string) as {
+ version?: unknown
+ }
+ expect(header.version, path).toBe(1)
+ }
})
- it('seeds the cold-list title cache against the main log identity', () => {
- const cache = JSON.parse(readFileSync(
+ it('keeps the committed cache stale while the generator owns the current projection', () => {
+ const committed = JSON.parse(readFileSync(
join(VFS_EXAMPLE_ROOT, 'home/storages/session_projcache.json'),
'utf8',
)) as {
@@ -74,8 +91,17 @@ describe('WebWorker preview VFS example', () => {
}>
}
}
- expect(cache.unit).toEqual({ name: 'session_projcache', version: 6 })
- expect(cache.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]).toMatchObject({
+ expect(committed.unit).toEqual({ name: projectionCacheDomainSpec.name, version: 6 })
+ expect(committed.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]?.identity.formatVersion).toBe(1)
+
+ const generatedText = buildVfsExampleFiles().get('home/storages/session_projcache.json')
+ if (generatedText === undefined) throw new Error('missing generated VFS example projection cache')
+ const generated = JSON.parse(generatedText) as typeof committed
+ expect(generated.unit).toEqual({
+ name: projectionCacheDomainSpec.name,
+ version: projectionCacheDomainSpec.version,
+ })
+ expect(generated.tables.sessions[VFS_EXAMPLE_SESSION_IDS.main]).toMatchObject({
identity: {
formatVersion: SESSION_FORMAT_VERSION,
createdAt: 1_787_472_000_000,
diff --git a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts
index c648c9b582..f0a97ccef1 100644
--- a/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts
+++ b/packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts
@@ -81,7 +81,6 @@ interface EventDraft {
readonly type: string
readonly data: unknown
readonly surfaceOp?: 'append'
- readonly sourceEventSeqs?: SessionSeqType[]
readonly ignorable?: true
}
@@ -114,6 +113,38 @@ function userMessage(id: string, text: string): EventDraft {
}
}
+function assistantStream(content: readonly unknown[]): unknown[] {
+ const stream: unknown[] = []
+ let toolCalls = false
+ for (const [index, value] of content.entries()) {
+ const block = value as Record
+ stream.push({ type: 'chunk', time: 0, chunk: { type: 'block-start', index, blockType: block.type } })
+ if (block.type === 'text') {
+ stream.push({ type: 'text-chunks', time0: 0, index, dt: [], texts: [block.text] })
+ } else if (block.type === 'reasoning') {
+ stream.push({ type: 'reasoning-chunks', time0: 0, index, dt: [], texts: [block.text] })
+ } else if (block.type === 'tool-call') {
+ toolCalls = true
+ stream.push({
+ type: 'tool-call-chunks',
+ time0: 0,
+ index,
+ dt: [],
+ id: block.id,
+ name: block.name,
+ args: [block.arguments],
+ })
+ }
+ stream.push({ type: 'chunk', time: 0, chunk: { type: 'block-end', index, block } })
+ }
+ stream.push({
+ type: 'chunk',
+ time: 0,
+ chunk: { type: 'finish', reason: { kind: toolCalls ? 'tool-calls' : 'stop' } },
+ })
+ return stream
+}
+
function assistantMessage(id: string, turn: number, step: number, content: unknown[]): EventDraft {
return {
type: 'assistant/message',
@@ -126,8 +157,8 @@ function assistantMessage(id: string, turn: number, step: number, content: unkno
content,
source: { kind: 'model', provider: 'preview-fixture', model: 'deterministic' },
},
+ stream: assistantStream(content),
},
- sourceEventSeqs: [],
surfaceOp: 'append',
}
}
@@ -333,7 +364,7 @@ function mainLog(): {
function oneShotLog(seed: readonly SessionEvent[]): SessionEvent[] {
const log = new EventLog(CREATED_AT + 100_000, seed)
- log.add({ type: 'session/end-seed', data: {} })
+ log.add({ type: 'session/end-seed', data: { inherited: true } })
const turn = HISTORICAL_TURNS + 1
log.add({ type: 'turn/start', data: { turn } })
log.add(userMessage('preview-review-user', 'Review whether the preview fixture is isolated from future WebFS data.'))
@@ -405,7 +436,7 @@ function renderLog(
storage: { readonly meta: SessionHeader; readonly inheritedEventCount: SessionLogOffsetType },
events: readonly SessionEvent[],
): string {
- return `${JSON.stringify(toHeaderLine(storage.meta, storage.inheritedEventCount))}\n${eventLines(events, true)}\n`
+ return `${JSON.stringify(toHeaderLine(storage.meta, storage.inheritedEventCount))}\n${eventLines(events)}\n`
}
/** Build every committed fixture file as repository-relative UTF-8 text. */
diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
index 00c7b4a4b2..276d823c20 100644
--- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
@@ -433,6 +433,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentContext',
declaration: 'export type AgentContext = Omit & {\n readonly remote: ClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};',
},
+ {
+ name: 'AssistantLiveChunkEvent',
+ declaration: 'export interface AssistantLiveChunkEvent {\n readonly type: \'assistant/live-chunk\';\n readonly seq: number;\n readonly time: number;\n readonly data: {\n readonly attemptId: LlmAttemptId;\n readonly turn: number;\n readonly step: number;\n readonly chunk: StreamChunk;\n };\n}',
+ },
{
name: 'BakedActions',
declaration: 'export type BakedActions> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};',
@@ -461,10 +465,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ChildrenDecl',
declaration: 'export type ChildrenDecl = {\n [P in keyof SlotMap & string]?: SlotSpec;\n};',
},
- {
- name: 'ChunkRowEvent',
- declaration: 'export type ChunkRowEvent = {\n [Kind in ChunkRow[\'type\']]: {\n readonly type: `chunkrow/${Kind}`;\n readonly seq: number;\n readonly time: number;\n readonly data: Extract[\'data\'];\n };\n}[ChunkRow[\'type\']];',
- },
{
name: 'ClientConnectionRpc',
declaration: 'export interface ClientConnectionRpc {\n call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise>;\n readonly open?: (channel: string, endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable;\n}',
@@ -699,11 +699,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventChange',
- declaration: 'export type SessionEventChange = {\n readonly kind: \'replace\';\n readonly entries: readonly SessionEventLikeEntry[];\n} | {\n readonly kind: \'prepend\';\n readonly entries: readonly SessionEventLikeEntry[];\n} | {\n readonly kind: \'append\';\n readonly entries: readonly SessionLiveEventEntry[];\n};',
+ declaration: 'export type SessionEventChange = {\n readonly kind: \'replace\';\n readonly entries: readonly SessionEventLikeEntry[];\n} | {\n readonly kind: \'prepend\';\n readonly entries: readonly SessionEventLikeEntry[];\n} | {\n readonly kind: \'append\';\n readonly entries: readonly SessionEventLikeEntry[];\n};',
},
{
name: 'SessionEventLikeEntry',
- declaration: 'export type SessionEventLikeEntry = {\n readonly type: \'event\';\n readonly event: SessionEvent;\n} | {\n readonly type: \'chunks\';\n readonly event: ChunkRowEvent;\n};',
+ declaration: 'export type SessionEventLikeEntry = {\n readonly type: \'event\';\n readonly event: SessionEvent;\n} | {\n readonly type: \'transient\';\n readonly event: AssistantLiveChunkEvent;\n};',
},
{
name: 'SessionEventSource',
@@ -721,10 +721,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionIdOf',
declaration: 'export type SessionIdOf = SessionStandardProps extends {\n sessionId: infer S;\n} ? S : string;',
},
- {
- name: 'SessionLiveEventEntry',
- declaration: 'export type SessionLiveEventEntry = Extract;',
- },
{
name: 'SessionMaybeStandardProps',
declaration: 'export interface SessionMaybeStandardProps {\n}',
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 8e55375843..24e3243059 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -2895,7 +2895,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
mode: 'emit',
signature: '\'agent/assistant-stream\'(this: Scoped, payload: { agent: Agent; frame: AssistantStreamFrame }): void',
summary: 'Process-local assistant-stream publication.',
- description: 'Process-local assistant-stream publication. The loop appends each v1 `assistant/chunk` before the matching chunk frame and appends the final `assistant/message` before a committed end frame.',
+ description: 'Process-local assistant-stream publication. Chunk frames are transient; the loop appends one final v2 `assistant/message` or `assistant/attempt` with the same stream before a committed end frame.',
parameters: [{ name: 'payload', description: '.frame - one ordered start, chunk, or end publication. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }],
},
{
@@ -3548,7 +3548,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AssistantStreamFrame',
- declaration: 'export type AssistantStreamFrame = {\n readonly type: \'start\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly startedTime: number;\n readonly turn: number;\n readonly step: number;\n} | {\n readonly type: \'chunk\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly chunk: StreamChunk;\n readonly legacyChunkSeq: SessionSeq;\n} | {\n readonly type: \'end\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly outcome: \'committed\' | \'aborted\';\n readonly legacyChunkSeqs: readonly SessionSeq[];\n};',
+ declaration: 'export type AssistantStreamFrame = {\n readonly type: \'start\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly startedTime: number;\n readonly turn: number;\n readonly step: number;\n} | {\n readonly type: \'chunk\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly time: number;\n readonly chunk: StreamChunk;\n} | {\n readonly type: \'end\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly outcome: {\n readonly kind: \'committed\';\n readonly eventType: \'assistant/message\' | \'assistant/attempt\';\n readonly seq: SessionSeq;\n } | {\n readonly kind: \'abandoned\';\n };\n};',
+ },
+ {
+ name: 'AssistantStreamRecord',
+ declaration: 'export type AssistantStreamRecord = {\n readonly type: \'text-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly texts: readonly string[];\n} | {\n readonly type: \'reasoning-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly texts: readonly string[];\n} | {\n readonly type: \'tool-call-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly id: ToolCallId;\n readonly name?: string;\n readonly args: readonly string[];\n} | {\n readonly type: \'chunk\';\n readonly time: number;\n readonly chunk: StreamChunk;\n};',
},
{
name: 'AttachmentId',
@@ -3630,14 +3634,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'BrandedNumber',
declaration: 'export type BrandedNumber = number & {\n readonly [BRAND]: B;\n};',
},
- {
- name: 'ChunkRow',
- declaration: 'export type ChunkRow = {\n type: \'text-chunks\';\n seq0: SessionSeqType;\n time0: number;\n data: TextRunData;\n} | {\n type: \'reasoning-chunks\';\n seq0: SessionSeqType;\n time0: number;\n data: TextRunData;\n} | {\n type: \'tool-call-chunks\';\n seq0: SessionSeqType;\n time0: number;\n data: ToolCallRunData;\n};',
- },
- {
- name: 'ChunkRowEvent',
- declaration: 'export type ChunkRowEvent = {\n [Kind in ChunkRow[\'type\']]: {\n readonly type: `chunkrow/${Kind}`;\n readonly seq: number;\n readonly time: number;\n readonly data: Extract[\'data\'];\n };\n}[ChunkRow[\'type\']];',
- },
{
name: 'ClientArtifactBaseline',
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}',
@@ -4824,7 +4820,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Session',
- declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
+ declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAddress',
@@ -4832,15 +4828,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionAssistantStreamAttempt',
- declaration: 'export interface SessionAssistantStreamAttempt {\n readonly attemptId: LlmAttemptId;\n readonly startedTime: number;\n readonly turn: number;\n readonly step: number;\n readonly chunks: readonly JsonValue[];\n readonly legacyChunkSeqs: readonly number[];\n}',
+ declaration: 'export interface SessionAssistantStreamAttempt {\n readonly attemptId: LlmAttemptId;\n readonly startedTime: number;\n readonly turn: number;\n readonly step: number;\n readonly nextIndex: number;\n readonly stream: readonly JsonValue[];\n}',
},
{
name: 'SessionAssistantStreamBaseline',
- declaration: 'export interface SessionAssistantStreamBaseline {\n readonly revision: number;\n readonly attempts: readonly SessionAssistantStreamAttempt[];\n}',
+ declaration: 'export interface SessionAssistantStreamBaseline {\n readonly revision: number;\n readonly activeAttempt?: SessionAssistantStreamAttempt;\n}',
},
{
name: 'SessionAssistantStreamFrame',
- declaration: 'export type SessionAssistantStreamFrame = {\n readonly type: \'start\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly startedTime: number;\n readonly turn: number;\n readonly step: number;\n} | {\n readonly type: \'chunk\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly chunk: JsonValue;\n readonly legacyChunkSeq: number;\n} | {\n readonly type: \'end\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly outcome: \'committed\' | \'aborted\';\n readonly legacyChunkSeqs: readonly number[];\n};',
+ declaration: 'export type SessionAssistantStreamFrame = {\n readonly type: \'start\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly startedTime: number;\n readonly turn: number;\n readonly step: number;\n} | {\n readonly type: \'chunk\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly time: number;\n readonly chunk: JsonValue;\n} | {\n readonly type: \'end\';\n readonly attemptId: LlmAttemptId;\n readonly revision: number;\n readonly index: number;\n readonly outcome: {\n readonly kind: \'committed\';\n readonly eventType: \'assistant/message\' | \'assistant/attempt\';\n readonly seq: number;\n } | {\n readonly kind: \'abandoned\';\n };\n};',
},
{
name: 'SessionAttachmentRequest',
@@ -4862,10 +4858,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionCancelValue',
declaration: 'export interface SessionCancelValue {\n readonly accepted: true;\n}',
},
- {
- name: 'SessionChunkRun',
- declaration: 'export interface SessionChunkRun {\n readonly type: \'chunks\';\n readonly event: ChunkRowEvent;\n}',
- },
{
name: 'SessionControlBaseline',
declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}',
@@ -4892,7 +4884,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
- declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record;\n}',
+ declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n inherited?: true;\n };\n}',
},
{
name: 'SessionEventMetadataFilter',
@@ -4980,7 +4972,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionHistoryRecord',
- declaration: 'export type SessionHistoryRecord = SessionEventEntry | SessionChunkRun;',
+ declaration: 'export type SessionHistoryRecord = SessionEventEntry;',
},
{
name: 'SessionId',
@@ -5276,7 +5268,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionWireHeader',
- declaration: 'export interface SessionWireHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}',
+ declaration: 'export interface SessionWireHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}',
},
{
name: 'SessionWireSurfaceOp',
@@ -5616,7 +5608,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SurfaceIntent',
- declaration: 'export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: SessionSeq[];\n}',
+ declaration: 'export type SurfaceIntent = {\n surfaceOp: SurfaceOp;\n} & (T extends \'assistant/message\' ? {\n sourceEventSeqs?: never;\n} : {\n sourceEventSeqs?: SessionSeq[];\n});',
},
{
name: 'SurfaceOp',
diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts
index f23ec2f1fb..c96a15e741 100644
--- a/packages/feedback/message-feedback/tests/helpers.ts
+++ b/packages/feedback/message-feedback/tests/helpers.ts
@@ -31,7 +31,6 @@ export interface MessageFixture {
readonly userMessageId: MessageId
readonly assistantMessageIds: readonly [MessageId, MessageId]
readonly emptyAssistantMessageId: MessageId
- readonly replacementAssistantMessageId: MessageId
}
/** Append one deterministic transcript used by target-validation tests. */
@@ -48,7 +47,8 @@ export function appendMessageFixture(session: Session): Omit {
}))
})
- it('accepts only non-empty append-origin assistant projections as targets', async () => {
+ it('accepts only non-empty assistant projections as targets', async () => {
const { ctx, persistence } = await harness()
const fixture = messageFixture('targets')
persistence.persist(fixture.session)
const rejectedTargets: MessageId[] = [
fixture.userMessageId,
fixture.emptyAssistantMessageId,
- fixture.replacementAssistantMessageId,
]
for (const messageId of rejectedTargets) {
await expect(ctx.messageFeedback.put({
diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts
index 1516ff20a4..4e9d5bb726 100644
--- a/packages/llm/llm-retry/tests/retry.spec.ts
+++ b/packages/llm/llm-retry/tests/retry.spec.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
-import LlmRuntime, { createUserMessage, ToolCallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
+import LlmRuntime, { createUserMessage, ToolCallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, expandAssistantStream, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
import type {
AlwaysRetryPolicyConfig,
BackoffConfig,
@@ -287,20 +287,19 @@ describe('provider-routed retry policy', () => {
await idle
const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry')
- const failedChunks = agent.session.snapshotEvents().filter(event =>
- event.type === 'assistant/chunk'
+ const failedAttempts = agent.session.snapshotEvents().filter((event): event is SessionEvent<'assistant/attempt'> =>
+ event.type === 'assistant/attempt'
&& retryEvent !== undefined
&& event.seq < retryEvent.seq,
)
- expect(failedChunks).toHaveLength(7)
+ expect(failedAttempts).toHaveLength(1)
+ expect(expandAssistantStream(failedAttempts[0]!.data.stream)).toHaveLength(7)
const assistantMessages = agent.session.snapshotEvents().filter(event => event.type === 'assistant/message')
expect(assistantMessages.map(event => ({
turn: event.data.turn,
step: event.data.step,
}))).toEqual([{ turn: 1, step: 1 }])
- expect(failedChunks.every(event =>
- !assistantMessages[0]?.sourceEventSeqs?.includes(event.seq),
- )).toBe(true)
+ expect(assistantMessages[0]?.sourceEventSeqs).toBeUndefined()
expect(agent.session.snapshotEvents().some(event => event.type === 'tool/call')).toBe(false)
expect(toolExecutions).toBe(0)
expect(agent.session.deriveMessages().at(-1)).toMatchObject({
diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts
index c9b8c7eaca..78e9d864a1 100644
--- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts
+++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts
@@ -1,4 +1,4 @@
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { createUserMessage, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as Retry from '../src/index.ts'
@@ -133,11 +134,13 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
expect(server.requests).toHaveLength(2)
expect(server.requests[0]?.body).toEqual(server.requests[1]?.body)
const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry')
- expect(agent.session.snapshotEvents().filter(event =>
- event.type === 'assistant/chunk'
+ const failedAttempts = agent.session.snapshotEvents().filter((event): event is SessionEvent<'assistant/attempt'> =>
+ event.type === 'assistant/attempt'
&& retryEvent !== undefined
&& event.seq < retryEvent.seq,
- )).toHaveLength(failedChunkCount)
+ )
+ expect(failedAttempts).toHaveLength(1)
+ expect(expandAssistantStream(failedAttempts[0]!.data.stream)).toHaveLength(failedChunkCount)
expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message')
.map(event => [event.data.turn, event.data.step]))
.toEqual([[1, 1]])
@@ -188,9 +191,8 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(1)
- expect(agent.session.snapshotEvents().filter(event =>
- event.type === 'assistant/chunk' && event.data.turn === 1,
- )).toHaveLength(3)
+ const attempt = agent.session.snapshotEvents().find(event => event.type === 'assistant/attempt' && event.data.turn === 1)
+ expect(attempt?.type === 'assistant/attempt' ? expandAssistantStream(attempt.data.stream) : []).toHaveLength(3)
expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false)
expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false)
expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml
index e5a5b22f6a..647c1a8e2b 100644
--- a/packages/llm/llm/README.i18n.yaml
+++ b/packages/llm/llm/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
-README.md: 6baa020a9503f32a71231d30e78ec7a3d8862f2b
-README.zh.md: f985736da44a76b063f16915a53cb19979fe1962
+README.md: 28ffe082b35715b4c013a5365c741fb49af12a4b
+README.zh.md: eb2aba3bb3457949b429c347b93988637f7b4eb8
diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md
index 6baa020a95..28ffe082b3 100644
--- a/packages/llm/llm/README.md
+++ b/packages/llm/llm/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`@deepseek-ai/dsh-llm` is the provider-neutral model-call service at the center of the harness's LLM capability. Every composition that streams a request to a model provider goes through it, and it owns the shared vocabulary — messages, content blocks, and raw stream chunks — that the agent loop, session log, and every plugin speak. With it you can register provider adapters, stream one model call, list and discover models, resolve exact-model metadata and call defaults, and capture each provider's retry policy; every request is logged so it stays reconstructable from the session log. It executes no retries and owns no provider wire logic: adapters translate their provider's format, and the optional `dsh-llm-retry` package re-runs failed requests at durable step boundaries. Requests are deep-frozen before dispatch, so middleware and adapters can read them but never rewrite them.
+`@deepseek-ai/dsh-llm` is the provider-neutral model-call service at the center of the harness's LLM capability. Every composition that streams a request to a model provider goes through it, and it owns the shared vocabulary — messages, content blocks, raw stream chunks, and compact Assistant stream records — that the agent loop, session log, and every plugin speak. With it you can register provider adapters, stream one model call, list and discover models, resolve exact-model metadata and call defaults, and capture each provider's retry policy; every request is logged so it stays reconstructable from the session log. It executes no retries and owns no provider wire logic: adapters translate their provider's format, and the optional `dsh-llm-retry` package re-runs failed requests at durable step boundaries. Requests are deep-frozen before dispatch, so middleware and adapters can read them but never rewrite them.
## Table of Contents
@@ -42,7 +42,7 @@ Mount the service and at least one adapter, then select the provider by name in
apiKeyEnv: DEEPSEEK_API_KEY
```
-A stream returns token-level chunks and always ends with one terminal `finish` chunk; `BlockAssembler` turns the chunks into content blocks and messages, and the loop logs each chunk for replay:
+A stream returns token-level chunks and always ends with one terminal `finish` chunk. `BlockAssembler` turns the chunks into content blocks and messages; `AssistantStreamAccumulator` preserves their exact timestamps and token boundaries in a compact representation that the loop embeds in one durable attempt settlement:
```text
for await (const chunk of ctx.llm.stream({
@@ -90,6 +90,7 @@ The service is built on one separation: **the logical contract is provider-neutr
| [`src/types.ts`](src/types.ts) | The `StreamChunk` protocol, content-block map, finish reasons, and shared vocabulary |
| [`src/message.ts`](src/message.ts) | Immutable message constructors shared by delivery, history, and requests |
| [`src/assembler.ts`](src/assembler.ts) | `BlockAssembler`: incremental chunk-to-block assembly |
+| [`src/assistant-stream.ts`](src/assistant-stream.ts) | Compact timed Assistant stream accumulation, strict validation, and exact expansion |
| [`src/call-config.ts`](src/call-config.ts) | Call-config validation, adapter-default materialization, and request freezing |
| [`src/retry-policy.ts`](src/retry-policy.ts) | Provider-owned retry policy resolution (normal and always modes) |
| [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` taxonomy and provider-neutral failure codes |
@@ -119,7 +120,7 @@ A request is validated against its exact model's capability — context window,
Read these pages when the package-level contract is not enough. They move from the shared types to the concrete adapters, the retry executor, and the measurement service.
-- [LLM streaming subsystem](../../../docs/subsystems/llm-streaming.md) — the message and block types, the assembled model request, the `StreamChunk` protocol, and the adapter contract.
+- [LLM streaming subsystem](../../../docs/subsystems/llm-streaming.md) — the message and block types, compact Assistant stream records, the `StreamChunk` protocol, and the adapter contract.
- [llm-deepseek adapter](../llm-deepseek/README.md) — the direct DeepSeek chat-completions implementation.
- [llm-pi-ai adapter](../llm-pi-ai/README.md) — the pi-ai-backed multi-provider implementation.
- [llm-retry](../llm-retry/README.md) — the retry executor that re-runs failed model requests.
diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md
index f985736da4..eb2aba3bb3 100644
--- a/packages/llm/llm/README.zh.md
+++ b/packages/llm/llm/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`@deepseek-ai/dsh-llm` 是位于 harness LLM 能力核心的提供方无关模型调用服务。任何向模型提供方发起流式请求的组合都会经过它,它拥有 agent loop(智能体循环)、会话日志和所有插件共同使用的共享词汇——消息、内容块与原始流式分片。借助它,你可以注册提供方适配器、流式发起一次模型调用、列出与发现模型、解析精确模型元数据与调用默认值,并捕获每个提供方的重试策略;每个请求都会被记录,因此始终可以从会话日志重建。它不执行重试,也不拥有任何提供方协议逻辑:适配器翻译各自提供方的格式,可选包 `dsh-llm-retry` 在持久步骤边界上重跑失败的请求。请求在分发前会被深度冻结,因此 middleware 与适配器只能读取,绝不能改写。
+`@deepseek-ai/dsh-llm` 是位于 harness LLM 能力核心的提供方无关模型调用服务。任何向模型提供方发起流式请求的组合都会经过它,它拥有 agent loop(智能体循环)、会话日志和所有插件共同使用的共享词汇——消息、内容块、原始流式分片与紧凑 Assistant stream record。借助它,你可以注册提供方适配器、流式发起一次模型调用、列出与发现模型、解析精确模型元数据与调用默认值,并捕获每个提供方的重试策略;每个请求都会被记录,因此始终可以从会话日志重建。它不执行重试,也不拥有任何提供方协议逻辑:适配器翻译各自提供方的格式,可选包 `dsh-llm-retry` 在持久步骤边界上重跑失败的请求。请求在分发前会被深度冻结,因此 middleware 与适配器只能读取,绝不能改写。
## 目录
@@ -42,7 +42,7 @@ kind: "package-reference"
apiKeyEnv: DEEPSEEK_API_KEY
```
-流会返回 token 级分片,并始终以一个终止 `finish` 分片结束;`BlockAssembler` 把分片组装为内容块与消息,loop 记录每个分片以供回放:
+流会返回 token 级分片,并始终以一个终止 `finish` 分片结束。`BlockAssembler` 把分片组装为内容块与消息;`AssistantStreamAccumulator` 在紧凑表示中保留其精确时间戳与 token 边界,loop 再把它嵌入一个持久 attempt settlement:
```text
for await (const chunk of ctx.llm.stream({
@@ -90,6 +90,7 @@ for await (const chunk of ctx.llm.stream({
| [`src/types.ts`](src/types.ts) | `StreamChunk` 协议、内容块映射、结束原因与共享词汇 |
| [`src/message.ts`](src/message.ts) | 投递、历史与请求共享的不可变消息构造函数 |
| [`src/assembler.ts`](src/assembler.ts) | `BlockAssembler`:分片到块的增量组装 |
+| [`src/assistant-stream.ts`](src/assistant-stream.ts) | 紧凑带时间 Assistant stream 的累积、严格校验与精确展开 |
| [`src/call-config.ts`](src/call-config.ts) | 调用配置校验、适配器默认值填入与请求冻结 |
| [`src/retry-policy.ts`](src/retry-policy.ts) | 提供方自有重试策略解析(normal 与 always 模式) |
| [`src/error.ts`](src/error.ts) | `HarnessError`/`LlmError` 分类体系与提供方无关失败 code |
@@ -119,7 +120,7 @@ for await (const chunk of ctx.llm.stream({
当包级约定不够用时阅读以下页面。它们从共享类型逐步进入具体适配器、重试执行器与计量服务。
-- [LLM 流式子系统](../../../docs/subsystems/llm-streaming.zh.md)——消息与块类型、组装后的模型请求、`StreamChunk` 协议与适配器约定。
+- [LLM 流式子系统](../../../docs/subsystems/llm-streaming.zh.md)——消息与块类型、紧凑 Assistant stream record、`StreamChunk` 协议与适配器约定。
- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——DeepSeek chat-completions 直连实现。
- [llm-pi-ai 适配器](../llm-pi-ai/README.zh.md)——基于 pi-ai 的多提供方实现。
- [llm-retry](../llm-retry/README.zh.md)——重跑失败模型请求的重试执行器。
diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json
index ce1a4c466a..b43917cc7f 100644
--- a/packages/llm/llm/package.json
+++ b/packages/llm/llm/package.json
@@ -34,6 +34,10 @@
"types": "./lib/types/message.d.ts",
"default": "./lib/types/message.js"
},
+ "./assistant-stream": {
+ "types": "./lib/types/assistant-stream.d.ts",
+ "default": "./lib/types/assistant-stream.js"
+ },
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
diff --git a/packages/llm/llm/src/assistant-stream.ts b/packages/llm/llm/src/assistant-stream.ts
new file mode 100644
index 0000000000..5accb35cf2
--- /dev/null
+++ b/packages/llm/llm/src/assistant-stream.ts
@@ -0,0 +1,268 @@
+/** Lossless compact representation of one model-stream attempt. */
+
+import { deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
+import type { ToolCallId } from './brand.ts'
+import type { StreamChunk } from './types.ts'
+
+/** One model chunk paired with its original Session timestamp. */
+export interface TimedStreamChunk {
+ readonly time: number
+ readonly chunk: StreamChunk
+}
+
+/** Lossless compact records embedded in durable Assistant attempt events. */
+export type AssistantStreamRecord =
+ | {
+ readonly type: 'text-chunks'
+ readonly time0: number
+ readonly index: number
+ readonly dt: readonly number[]
+ readonly texts: readonly string[]
+ }
+ | {
+ readonly type: 'reasoning-chunks'
+ readonly time0: number
+ readonly index: number
+ readonly dt: readonly number[]
+ readonly texts: readonly string[]
+ }
+ | {
+ readonly type: 'tool-call-chunks'
+ readonly time0: number
+ readonly index: number
+ readonly dt: readonly number[]
+ readonly id: ToolCallId
+ readonly name?: string
+ readonly args: readonly string[]
+ }
+ | { readonly type: 'chunk'; readonly time: number; readonly chunk: StreamChunk }
+
+type MutableRecord =
+ | {
+ type: 'text-chunks' | 'reasoning-chunks'
+ time0: number
+ index: number
+ dt: number[]
+ texts: string[]
+ lastTime: number
+ }
+ | {
+ type: 'tool-call-chunks'
+ time0: number
+ index: number
+ dt: number[]
+ id: ToolCallId
+ name?: string
+ args: string[]
+ lastTime: number
+ }
+ | { type: 'chunk'; time: number; chunk: StreamChunk }
+
+function safeTime(value: number): number {
+ if (!Number.isSafeInteger(value)) throw new TypeError(`Assistant stream time must be a safe integer, got ${String(value)}`)
+ return value
+}
+
+function snapshotChunk(chunk: StreamChunk): StreamChunk {
+ const snapshot = snapshotJsonValue(chunk)
+ if (snapshot === undefined) throw new TypeError('Assistant stream chunk must be losslessly JSON-serializable')
+ return snapshot
+}
+
+function safeGap(previous: number, next: number): number | undefined {
+ const gap = next - previous
+ return Number.isSafeInteger(gap) && previous + gap === next ? gap : undefined
+}
+
+/** Incrementally compacts one attempt without retaining a second raw-chunk list. */
+export class AssistantStreamAccumulator {
+ private readonly records: MutableRecord[] = []
+
+ /**
+ * Add one timed chunk to the compact attempt stream.
+ * @param value - model chunk and its original Session timestamp.
+ * @returns a detached immutable copy for assembly and live publication.
+ */
+ push(value: TimedStreamChunk): TimedStreamChunk {
+ const time = safeTime(value.time)
+ const chunk = snapshotChunk(value.chunk)
+ const timed = deepFreeze({ time, chunk })
+ const previous = this.records.at(-1)
+ switch (chunk.type) {
+ case 'text-delta':
+ case 'reasoning-delta': {
+ const type = chunk.type === 'text-delta' ? 'text-chunks' : 'reasoning-chunks'
+ const gap = previous !== undefined && previous.type === type ? safeGap(previous.lastTime, time) : undefined
+ if (previous !== undefined && previous.type === type && previous.index === chunk.index && gap !== undefined) {
+ previous.dt.push(gap)
+ previous.texts.push(chunk.text)
+ previous.lastTime = time
+ } else {
+ this.records.push({ type, time0: time, index: chunk.index, dt: [], texts: [chunk.text], lastTime: time })
+ }
+ return timed
+ }
+ case 'tool-call-delta': {
+ const gap = previous?.type === 'tool-call-chunks' ? safeGap(previous.lastTime, time) : undefined
+ const sameName = previous?.type === 'tool-call-chunks'
+ && Object.hasOwn(previous, 'name') === Object.hasOwn(chunk, 'name')
+ && previous.name === chunk.name
+ if (previous?.type === 'tool-call-chunks'
+ && previous.index === chunk.index
+ && previous.id === chunk.id
+ && sameName
+ && gap !== undefined) {
+ previous.dt.push(gap)
+ previous.args.push(chunk.argumentsDelta)
+ previous.lastTime = time
+ } else {
+ this.records.push({
+ type: 'tool-call-chunks',
+ time0: time,
+ index: chunk.index,
+ dt: [],
+ id: chunk.id,
+ ...Object.hasOwn(chunk, 'name') ? { name: chunk.name } : {},
+ args: [chunk.argumentsDelta],
+ lastTime: time,
+ })
+ }
+ return timed
+ }
+ default:
+ this.records.push({ type: 'chunk', time, chunk })
+ }
+ return timed
+ }
+
+ /**
+ * Return the current compact attempt stream.
+ * @returns a detached immutable record list suitable for a durable event.
+ */
+ snapshot(): readonly AssistantStreamRecord[] {
+ const records = this.records.map((record): AssistantStreamRecord => {
+ if (record.type === 'chunk') return { ...record }
+ const { lastTime: _lastTime, ...durable } = record
+ if (durable.type === 'tool-call-chunks') {
+ return { ...durable, dt: [...durable.dt], args: [...durable.args] }
+ }
+ return { ...durable, dt: [...durable.dt], texts: [...durable.texts] }
+ })
+ return deepFreeze(records)
+ }
+}
+
+/**
+ * Expand compact records into the exact timed chunk sequence.
+ * @param stream - compact records from one durable Assistant settlement.
+ * @returns detached timed chunks with every original delta boundary preserved.
+ * @throws {TypeError} when a record or reconstructed timestamp is invalid.
+ */
+export function expandAssistantStream(stream: readonly AssistantStreamRecord[]): readonly TimedStreamChunk[] {
+ const chunks: TimedStreamChunk[] = []
+ for (const candidate of stream) {
+ const record = validateRecord(candidate)
+ if (record.type === 'chunk') {
+ chunks.push({ time: record.time, chunk: record.chunk })
+ continue
+ }
+ const members = record.type === 'tool-call-chunks' ? record.args : record.texts
+ let time = record.time0
+ for (let index = 0; index < members.length; index += 1) {
+ if (index > 0) time += record.dt[index - 1] as number
+ let chunk: StreamChunk
+ if (record.type === 'text-chunks') {
+ chunk = { type: 'text-delta', index: record.index, text: members[index] as string }
+ } else if (record.type === 'reasoning-chunks') {
+ chunk = { type: 'reasoning-delta', index: record.index, text: members[index] as string }
+ } else {
+ chunk = {
+ type: 'tool-call-delta',
+ index: record.index,
+ id: record.id,
+ ...Object.hasOwn(record, 'name') ? { name: record.name } : {},
+ argumentsDelta: members[index] as string,
+ }
+ }
+ chunks.push({ time, chunk })
+ }
+ }
+ return chunks
+}
+
+function validateRecord(value: unknown): AssistantStreamRecord {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new TypeError('Assistant stream record must be an object')
+ }
+ const record = value as Record
+ switch (record.type) {
+ case 'text-chunks':
+ case 'reasoning-chunks': {
+ exactKeys(record, ['type', 'time0', 'index', 'dt', 'texts'], record.type)
+ const texts = stringArray(record.texts, `${record.type} texts`)
+ if (texts.length === 0) throw new TypeError(`${record.type} texts must be non-empty`)
+ validateRun(record, texts.length, record.type)
+ return record as unknown as AssistantStreamRecord
+ }
+ case 'tool-call-chunks': {
+ const keys = Object.hasOwn(record, 'name')
+ ? ['type', 'time0', 'index', 'dt', 'id', 'name', 'args']
+ : ['type', 'time0', 'index', 'dt', 'id', 'args']
+ exactKeys(record, keys, record.type)
+ const args = stringArray(record.args, 'tool-call-chunks args')
+ if (args.length === 0) throw new TypeError('tool-call-chunks args must be non-empty')
+ if (typeof record.id !== 'string' || record.id.length === 0) {
+ throw new TypeError('tool-call-chunks id must be a non-empty string')
+ }
+ if (record.name !== undefined && (typeof record.name !== 'string' || record.name.length === 0)) {
+ throw new TypeError('tool-call-chunks name must be a non-empty string')
+ }
+ validateRun(record, args.length, record.type)
+ return record as unknown as AssistantStreamRecord
+ }
+ case 'chunk': {
+ exactKeys(record, ['type', 'time', 'chunk'], 'chunk')
+ safeTime(record.time as number)
+ if (snapshotJsonValue(record.chunk) === undefined
+ || typeof record.chunk !== 'object'
+ || record.chunk === null
+ || Array.isArray(record.chunk)) {
+ throw new TypeError('Assistant stream raw chunk must be a lossless JSON object')
+ }
+ return record as unknown as AssistantStreamRecord
+ }
+ default:
+ throw new TypeError(`Unsupported Assistant stream record ${JSON.stringify(record.type)}`)
+ }
+}
+
+function validateRun(record: Record, members: number, label: string): void {
+ safeTime(record.time0 as number)
+ if (!Number.isSafeInteger(record.index) || (record.index as number) < 0 || Object.is(record.index, -0)) {
+ throw new TypeError(`${label} index must be a non-negative safe integer`)
+ }
+ if (!Array.isArray(record.dt) || record.dt.some(value => !Number.isSafeInteger(value))) {
+ throw new TypeError(`${label} dt must contain safe integers`)
+ }
+ if (record.dt.length !== members - 1) {
+ throw new TypeError(`${label} dt length must be one less than its members`)
+ }
+ let time = record.time0 as number
+ for (const gap of record.dt as number[]) {
+ time += gap
+ if (!Number.isSafeInteger(time)) throw new TypeError(`${label} member times must stay safe integers`)
+ }
+}
+
+function stringArray(value: unknown, label: string): string[] {
+ if (!Array.isArray(value) || value.some(member => typeof member !== 'string')) {
+ throw new TypeError(`${label} must be a string array`)
+ }
+ return value as string[]
+}
+
+function exactKeys(record: Record, keys: readonly string[], label: string): void {
+ if (Object.keys(record).length !== keys.length || !keys.every(key => Object.hasOwn(record, key))) {
+ throw new TypeError(`${label} Assistant stream record must contain exactly ${keys.join(', ')}`)
+ }
+}
diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts
index cdecc4c96e..f5c4da4a32 100644
--- a/packages/llm/llm/src/index.ts
+++ b/packages/llm/llm/src/index.ts
@@ -40,6 +40,7 @@ export * from './error.ts'
export * from './api-key.ts'
export * from './types.ts'
export * from './content.ts'
+export * from './assistant-stream.ts'
export * from './message.ts'
export * from './retry-policy.ts'
export { BlockAssembler } from './assembler.ts'
diff --git a/packages/llm/llm/tests/assistant-stream.spec.ts b/packages/llm/llm/tests/assistant-stream.spec.ts
new file mode 100644
index 0000000000..c2a1e99968
--- /dev/null
+++ b/packages/llm/llm/tests/assistant-stream.spec.ts
@@ -0,0 +1,160 @@
+import { describe, expect, it } from 'vitest'
+import {
+ AssistantStreamAccumulator,
+ ToolCallId,
+ expandAssistantStream,
+} from '@deepseek-ai/dsh-llm'
+import type { TimedStreamChunk } from '@deepseek-ai/dsh-llm'
+
+describe('AssistantStreamAccumulator', () => {
+ it('keeps delta boundaries and timestamps while compacting one attempt', () => {
+ const chunks: readonly TimedStreamChunk[] = [
+ { time: 1_000, chunk: { type: 'text-delta', index: 0, text: 'hel' } },
+ { time: 1_006, chunk: { type: 'text-delta', index: 0, text: 'lo' } },
+ {
+ time: 1_008,
+ chunk: {
+ type: 'tool-call-delta',
+ index: 1,
+ id: ToolCallId('call-1'),
+ name: 'run_code',
+ argumentsDelta: '{',
+ },
+ },
+ {
+ time: 1_011,
+ chunk: {
+ type: 'tool-call-delta',
+ index: 1,
+ id: ToolCallId('call-1'),
+ name: 'run_code',
+ argumentsDelta: '}',
+ },
+ },
+ { time: 1_020, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+ ]
+ const accumulator = new AssistantStreamAccumulator()
+ for (const timed of chunks) accumulator.push(timed)
+
+ expect(accumulator.snapshot()).toStrictEqual([
+ { type: 'text-chunks', time0: 1_000, index: 0, dt: [6], texts: ['hel', 'lo'] },
+ {
+ type: 'tool-call-chunks',
+ time0: 1_008,
+ index: 1,
+ id: 'call-1',
+ name: 'run_code',
+ dt: [3],
+ args: ['{', '}'],
+ },
+ { type: 'chunk', time: 1_020, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+ ])
+ expect(expandAssistantStream(accumulator.snapshot())).toStrictEqual(chunks)
+ })
+
+ it('snapshots each admitted chunk once and detaches earlier compact views', () => {
+ const usage = { inputTokens: 3, outputTokens: 2 }
+ const accumulator = new AssistantStreamAccumulator()
+ accumulator.push({ time: 10, chunk: { type: 'text-delta', index: 0, text: 'a' } })
+ accumulator.push({ time: 11, chunk: { type: 'usage', usage } })
+ usage.inputTokens = 99
+
+ const first = accumulator.snapshot()
+ accumulator.push({ time: 12, chunk: { type: 'text-delta', index: 0, text: 'b' } })
+ const second = accumulator.snapshot()
+
+ expect(expandAssistantStream(first)).toStrictEqual([
+ { time: 10, chunk: { type: 'text-delta', index: 0, text: 'a' } },
+ { time: 11, chunk: { type: 'usage', usage: { inputTokens: 3, outputTokens: 2 } } },
+ ])
+ expect(expandAssistantStream(second)).toHaveLength(3)
+ expect(Object.isFrozen(first)).toBe(true)
+ expect(Object.isFrozen((first[0] as { texts: readonly string[] }).texts)).toBe(true)
+ })
+
+ it('keeps incompatible delta runs separate and expands reasoning and nameless tool calls', () => {
+ const accumulator = new AssistantStreamAccumulator()
+ const chunks: readonly TimedStreamChunk[] = [
+ { time: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'r1' } },
+ { time: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'r2' } },
+ { time: 3, chunk: { type: 'text-delta', index: 0, text: 'a' } },
+ { time: 4, chunk: { type: 'text-delta', index: 1, text: 'b' } },
+ {
+ time: 5,
+ chunk: { type: 'tool-call-delta', index: 0, id: ToolCallId('one'), argumentsDelta: '{' },
+ },
+ {
+ time: 6,
+ chunk: { type: 'tool-call-delta', index: 0, id: ToolCallId('one'), argumentsDelta: '}' },
+ },
+ {
+ time: 7,
+ chunk: {
+ type: 'tool-call-delta', index: 0, id: ToolCallId('one'), name: 'read', argumentsDelta: '',
+ },
+ },
+ {
+ time: 8,
+ chunk: {
+ type: 'tool-call-delta', index: 1, id: ToolCallId('two'), name: 'read', argumentsDelta: 'x',
+ },
+ },
+ { time: Number.MAX_SAFE_INTEGER, chunk: { type: 'text-delta', index: 1, text: 'far' } },
+ { time: Number.MIN_SAFE_INTEGER, chunk: { type: 'text-delta', index: 1, text: 'apart' } },
+ ]
+ for (const chunk of chunks) accumulator.push(chunk)
+
+ expect(expandAssistantStream(accumulator.snapshot())).toStrictEqual(chunks)
+ expect(accumulator.snapshot().map(record => record.type)).toStrictEqual([
+ 'reasoning-chunks',
+ 'text-chunks',
+ 'text-chunks',
+ 'tool-call-chunks',
+ 'tool-call-chunks',
+ 'tool-call-chunks',
+ 'text-chunks',
+ 'text-chunks',
+ ])
+ })
+
+ it('rejects unsafe admission before mutating the accumulator', () => {
+ const accumulator = new AssistantStreamAccumulator()
+ expect(() => accumulator.push({
+ time: 0.5,
+ chunk: { type: 'finish', reason: { kind: 'stop' } },
+ })).toThrow(/safe integer/)
+ expect(() => accumulator.push({
+ time: 1,
+ chunk: { type: 'future', callback: () => undefined } as never,
+ })).toThrow(/JSON-serializable/)
+ expect(accumulator.snapshot()).toStrictEqual([])
+ })
+
+ it.each([
+ [null, /must be an object/],
+ [[], /must be an object/],
+ [{ type: 'future' }, /Unsupported/],
+ [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: [] }, /non-empty/],
+ [{ type: 'reasoning-chunks', time0: 1, index: 0, dt: [], texts: [1] }, /string array/],
+ [{ type: 'text-chunks', time0: 0.5, index: 0, dt: [], texts: ['a'] }, /safe integer/],
+ [{ type: 'text-chunks', time0: 1, index: -0, dt: [], texts: ['a'] }, /index/],
+ [{ type: 'text-chunks', time0: 1, index: -1, dt: [], texts: ['a'] }, /index/],
+ [{ type: 'text-chunks', time0: 1, index: 0, dt: [0.5], texts: ['a', 'b'] }, /dt/],
+ [{ type: 'text-chunks', time0: 1, index: 0, dt: [1], texts: ['a'] }, /dt length/],
+ [{
+ type: 'text-chunks', time0: Number.MAX_SAFE_INTEGER, index: 0, dt: [1], texts: ['a', 'b'],
+ }, /member times/],
+ [{ type: 'tool-call-chunks', time0: 1, index: 0, id: '', dt: [], args: ['a'] }, /id/],
+ [{ type: 'tool-call-chunks', time0: 1, index: 0, id: 'id', name: '', dt: [], args: ['a'] }, /name/],
+ [{ type: 'tool-call-chunks', time0: 1, index: 0, id: 'id', dt: [], args: [] }, /non-empty/],
+ [{ type: 'tool-call-chunks', time0: 1, index: 0, id: 'id', dt: [], args: [1] }, /string array/],
+ [{ type: 'chunk', time: 0.5, chunk: { type: 'finish', reason: { kind: 'stop' } } }, /safe integer/],
+ [{ type: 'chunk', time: 1, chunk: null }, /lossless JSON object/],
+ [{ type: 'chunk', time: 1, chunk: [] }, /lossless JSON object/],
+ [{ type: 'chunk', time: 1, chunk: { type: 'future', bad: undefined } }, /lossless JSON object/],
+ [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'], extra: true }, /exactly/],
+ [{ type: 'chunk', time: 1, chunk: { type: 'finish', reason: { kind: 'stop' } }, extra: true }, /exactly/],
+ ])('rejects malformed compact record %#', (record, message) => {
+ expect(() => expandAssistantStream([record] as never)).toThrow(message)
+ })
+})
diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts
index 5e045223e3..f4976c5cc7 100644
--- a/packages/llm/token-meter/src/index.ts
+++ b/packages/llm/token-meter/src/index.ts
@@ -6,7 +6,7 @@
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import { BlockAssembler } from '@deepseek-ai/dsh-llm'
+import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import { deepFreeze } from '@deepseek-ai/dsh-util-values'
import type {
@@ -14,9 +14,14 @@ import type {
Session,
SessionEvent,
SessionLogOffset as SessionLogOffsetType,
- SessionSeq as SessionSeqType,
} from '@deepseek-ai/dsh-session'
-import { canonicalHeader, headerEquals, isSurfaceEvent, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
+import {
+ canonicalHeader,
+ headerEquals,
+ isSurfaceEvent,
+ SessionLogOffset,
+ SessionSeq,
+} from '@deepseek-ai/dsh-session'
// Type-only: activates the `ctx.sessionProjections` Context declaration.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {
@@ -216,7 +221,7 @@ export class TokenMeter extends Service {
while (state.consumedEvents < session.seq) {
// oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log
const event = session.eventAt(SessionSeq(state.consumedEvents))!
- this._foldEvent(session, state, event)
+ this._foldEvent(state, event)
state.consumedEvents = SessionLogOffset(state.consumedEvents + 1)
}
return state
@@ -227,7 +232,7 @@ export class TokenMeter extends Service {
* mutating replay state, so a malformed event remains unread on every
* retry instead of half-applying.
*/
- private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void {
+ private _foldEvent(state: ReplayState, event: SessionEvent): void {
let nextHeader = state.header
let nextStepStart = state.stepStart
let nextAnchor = state.anchor
@@ -275,7 +280,7 @@ export class TokenMeter extends Service {
nextAnchor = {
header: nextHeader,
nodes: stepStart.nodes,
- assistantTokens: this._estimateProviderAssistant(session, event, eventTokens),
+ assistantTokens: this._estimateProviderAssistant(event),
usage: event.data.usage,
}
} else {
@@ -297,41 +302,13 @@ export class TokenMeter extends Service {
}
/**
- * Reassemble provider output from the exact cited chunk seqs for a usage anchor.
- * Missing legacy source seqs conservatively treat the durable output as the
- * provider output; an explicit empty list prices a known empty stream.
+ * Reassemble provider output from the message's exact embedded stream.
*/
private _estimateProviderAssistant(
- session: Session,
event: SessionEvent<'assistant/message'>,
- durableEventTokens: number,
): number {
- const sourceSeqs = event.sourceEventSeqs
- if (sourceSeqs === undefined) return durableEventTokens
-
const assembler = new BlockAssembler()
- const seen = new Set()
- for (const seq of sourceSeqs) {
- if (seq >= event.seq) {
- throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not earlier`)
- }
- if (seen.has(seq)) {
- throw new Error(`token meter: assistant/message at seq ${event.seq} repeats source seq ${seq}`)
- }
- seen.add(seq)
- // Session construction validates contiguous seqs, and the explicit
- // earlier-than-assistant check above therefore guarantees existence.
- const source = session.eventAt(seq)
- // oxlint-disable-next-line typescript/no-non-null-assertion
- const sourceEvent = source!
- if (sourceEvent.type !== 'assistant/chunk') {
- throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} is not assistant/chunk`)
- }
- if (sourceEvent.data.turn !== event.data.turn || sourceEvent.data.step !== event.data.step) {
- throw new Error(`token meter: assistant/message at seq ${event.seq} source seq ${seq} belongs to another step`)
- }
- assembler.push(sourceEvent.data.chunk)
- }
+ for (const member of expandAssistantStream(event.data.stream)) assembler.push(member.chunk)
const providerContent = assembler.blocks()
return providerContent.length === 0 ? 0 : estimateContent(providerContent) + ROLE_OVERHEAD
}
diff --git a/packages/llm/token-meter/src/turn-usage.ts b/packages/llm/token-meter/src/turn-usage.ts
index ba23f02b3b..47480dc9a8 100644
--- a/packages/llm/token-meter/src/turn-usage.ts
+++ b/packages/llm/token-meter/src/turn-usage.ts
@@ -1,3 +1,4 @@
+import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { AssistantMessage, TokenUsage } from '@deepseek-ai/dsh-llm/types'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -73,6 +74,14 @@ function messageRoute(message: AssistantMessage): TurnTokenUsageRoute | undefine
return provider.length > 0 && model.length > 0 ? { provider, model } : undefined
}
+function streamUsage(stream: SessionEvent<'assistant/message'>['data']['stream']): TokenUsage | undefined {
+ let sample: TokenUsage | undefined
+ for (const member of expandAssistantStream(stream)) {
+ if (member.chunk.type === 'usage') sample = member.chunk.usage
+ }
+ return sample
+}
+
function normalizeUsage(usage: TokenUsage, route?: TurnTokenUsageRoute): NormalizedAttempt | undefined {
const {
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens,
@@ -218,20 +227,20 @@ export function deriveTurnTokenUsage(events: readonly SessionEvent[]): TurnToken
else state = { kind: 'open', turn, step: event.data.step }
continue
}
- if (event.type === 'assistant/chunk') {
+ if (event.type === 'assistant/attempt') {
if (event.data.turn !== turn
|| state.kind !== 'open'
|| !sameAttempt(state, event.data.turn, event.data.step)) {
invalid = true
continue
}
- if (event.data.chunk.type === 'usage') {
- state = { ...state, sample: event.data.chunk.usage }
- } else if (event.data.chunk.type === 'finish'
- && (event.data.chunk.reason.kind === 'error' || event.data.chunk.reason.kind === 'aborted')) {
- if (!closeOpen()) invalid = true
- else state = { kind: 'finishClosed', turn, step: event.data.step }
+ let sample: TokenUsage | undefined = state.sample
+ for (const member of expandAssistantStream(event.data.stream)) {
+ if (member.chunk.type === 'usage') sample = member.chunk.usage
}
+ state = { kind: 'open', turn, step: event.data.step, ...(sample === undefined ? {} : { sample }) }
+ if (!closeOpen()) invalid = true
+ else state = { kind: 'finishClosed', turn, step: event.data.step }
continue
}
if (event.type === 'assistant/message') {
@@ -241,7 +250,8 @@ export function deriveTurnTokenUsage(events: readonly SessionEvent[]): TurnToken
invalid = true
continue
}
- if (event.data.usage !== undefined) state = { ...state, sample: event.data.usage }
+ const sample = event.data.usage ?? streamUsage(event.data.stream)
+ if (sample !== undefined) state = { ...state, sample }
if (!closeOpen(messageRoute(event.data.message))) invalid = true
else state = { kind: 'settled', turn, step: event.data.step, by: 'message' }
continue
diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts
index b98b48c3e1..eddecb5715 100644
--- a/packages/llm/token-meter/src/usage-projection.ts
+++ b/packages/llm/token-meter/src/usage-projection.ts
@@ -3,7 +3,7 @@
*/
import { z } from 'zod'
-import type { TokenUsage } from '@deepseek-ai/dsh-llm'
+import { expandAssistantStream, type TokenUsage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry/types'
import { SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -78,13 +78,15 @@ const pressureSchema: z.ZodType = z.object({
const pressureFrom = (usage: TokenUsage): number =>
usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
-/** The usage a chunk or finalized message reports for its step, if any. */
-const usageOf = (event: SessionEvent): TokenUsage | undefined =>
- event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
- ? event.data.chunk.usage
- : event.type === 'assistant/message'
- ? event.data.usage
- : undefined
+/** The usage one durable Assistant settlement reports for its attempt, if any. */
+function usageOf(event: SessionEvent): TokenUsage | undefined {
+ if (event.type === 'assistant/message' && event.data.usage !== undefined) return event.data.usage
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') return undefined
+ for (const member of expandAssistantStream(event.data.stream).toReversed()) {
+ if (member.chunk.type === 'usage') return member.chunk.usage
+ }
+ return undefined
+}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
@@ -111,12 +113,9 @@ type ContextPressureState = z.infer
/**
* Token-meter's session projection unit.
*
- * Usage chunks provide an early sample that survives a later request failure;
- * an assistant message provides the final sample for the same attempt. A
- * repeated sample replaces that attempt's earlier value instead of double
- * counting it, while `llm/retry-started` closes the replacement slot so the
- * retried attempt adds to the total. The single `last` slot relies on the
- * session-log invariant that usage reports for one attempt are adjacent.
+ * Each v2 Assistant settlement contributes the last usage sample embedded in
+ * its stream. `llm/retry-started` closes the replacement slot so the retried
+ * attempt adds to the total.
*/
export const tokenUsageProjectionDefinition = {
key: 'tokenUsage',
@@ -129,17 +128,13 @@ export const tokenUsageProjectionDefinition = {
? { ...state, last: null }
: state
}
- let turn: number
- let step: number
- let usage: TokenUsage
- if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
- ;({ turn, step } = event.data)
- usage = event.data.chunk.usage
- } else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
- ;({ turn, step, usage } = event.data)
- } else {
+ if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt') {
return state
}
+ const sample = usageOf(event)
+ if (sample === undefined) return state
+ const { turn, step } = event.data
+ const usage: TokenUsage = sample
const buckets = bucketsFrom(usage)
const previous = state.last !== null
diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
index 3f36926f57..37bcfdcf35 100644
--- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
+++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
@@ -107,6 +107,7 @@ describe('contextBreakdown session projection', () => {
appendUser(session, 'abcd')
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -115,7 +116,7 @@ describe('contextBreakdown session projection', () => {
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage: { inputTokens: 9, outputTokens: 0 },
- }, { surfaceOp: 'append', sourceEventSeqs: [] })
+ }, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
// 'abcd' prices to 9 (1 text + 4 block + 4 role); the usage-only assistant
// message derives to no transcript entry and adds nothing.
@@ -156,6 +157,7 @@ describe('contextBreakdown session projection', () => {
const question = appendUser(session, 'a first question, long enough to price above zero')
session.append('step/start', { turn: 1, step: 1 })
const answer = session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -164,7 +166,7 @@ describe('contextBreakdown session projection', () => {
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage: { inputTokens: 40, outputTokens: 7 },
- }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
+ }, { surfaceOp: 'append' }).seq
session.append('step/end', { turn: 1, step: 1 })
const grown = agree()
expect(grown).toBeGreaterThan(0)
diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts
index 37ef87ce04..5dcec59d0f 100644
--- a/packages/llm/token-meter/tests/route-pricing.spec.ts
+++ b/packages/llm/token-meter/tests/route-pricing.spec.ts
@@ -84,6 +84,7 @@ function appendSuccessfulCall(session: Session, value: EpochHeader, usage?: Toke
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', { header: value, reason: 'initial' })
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts
index 615a12c9dd..5299598e58 100644
--- a/packages/llm/token-meter/tests/token-meter.spec.ts
+++ b/packages/llm/token-meter/tests/token-meter.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
-import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm'
+import { AssistantStreamAccumulator, createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SessionSeq, canonicalHeader } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session'
@@ -38,7 +38,6 @@ interface SuccessfulCallOptions {
providerText?: string
durableText?: string
usage?: TokenUsage
- provenance?: 'exact' | 'empty' | 'absent'
}
function appendSuccessfulCall(
@@ -50,28 +49,20 @@ function appendSuccessfulCall(
const step = options.step ?? 1
const providerText = options.providerText ?? 'provider answer'
const durableText = options.durableText ?? providerText
- const provenance = options.provenance ?? 'exact'
session.append('step/start', { turn, step })
appendHeader(session, value)
- const sources: SessionSeqType[] = []
- if (provenance === 'exact') {
- const chunks = [
- { type: 'block-start' as const, index: 0, blockType: 'text' as const },
- { type: 'text-delta' as const, index: 0, text: providerText },
- { type: 'block-end' as const, index: 0, block: { type: 'text' as const, text: providerText } },
- ...options.usage === undefined ? [] : [{ type: 'usage' as const, usage: options.usage }],
- { type: 'finish' as const, reason: { kind: 'stop' as const } },
- ]
- for (const chunk of chunks) {
- sources.push(session.append('assistant/chunk', { turn, step, chunk }).seq)
- }
- }
-
- const intent = provenance === 'absent'
- ? { surfaceOp: 'append' as const }
- : { surfaceOp: 'append' as const, sourceEventSeqs: provenance === 'empty' ? [] : sources }
+ const chunks = [
+ { type: 'block-start' as const, index: 0, blockType: 'text' as const },
+ { type: 'text-delta' as const, index: 0, text: providerText },
+ { type: 'block-end' as const, index: 0, block: { type: 'text' as const, text: providerText } },
+ ...options.usage === undefined ? [] : [{ type: 'usage' as const, usage: options.usage }],
+ { type: 'finish' as const, reason: { kind: 'stop' as const } },
+ ]
+ const accumulator = new AssistantStreamAccumulator()
+ for (const [index, chunk] of chunks.entries()) accumulator.push({ time: index, chunk })
session.append('assistant/message', {
+ stream: [...accumulator.snapshot()],
turn,
step,
message: createMessage({
@@ -86,7 +77,7 @@ function appendSuccessfulCall(
},
}),
...options.usage === undefined ? {} : { usage: options.usage },
- }, intent)
+ }, { surfaceOp: 'append' })
session.append('step/end', { turn, step })
}
@@ -320,26 +311,6 @@ describe('replay anchors and surface folds', () => {
expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0)
})
- it('distinguishes an explicit empty source-event list from an absent legacy list', () => {
- const explicit = Session.create(SessionId('explicit-empty'))
- const legacy = Session.create(SessionId('legacy-absent'))
- appendSuccessfulCall(explicit, header('deepseek-v4-flash'), {
- durableText: 'listener injected text',
- providerText: '',
- usage: USAGE,
- provenance: 'empty',
- })
- appendSuccessfulCall(legacy, header('deepseek-v4-flash'), {
- durableText: 'listener injected text',
- providerText: '',
- usage: USAGE,
- provenance: 'absent',
- })
- const service = meter()
- expect(service.measure(explicit).surfaceDeltaTokens).toBeGreaterThan(0)
- expect(service.measure(legacy).surfaceDeltaTokens).toBe(0)
- })
-
it('keeps only the latest successful request anchor across model switches', () => {
const service = meter()
const session = Session.create(SessionId('switch'))
@@ -434,7 +405,6 @@ describe('replay anchors and surface folds', () => {
appendSuccessfulCall(session, header('deepseek-v4-flash'), {
providerText: '',
durableText: '',
- provenance: 'empty',
})
const measurement = meter().measure(session)
const assistant = session.snapshotEvents().find(event => event.type === 'assistant/message')!
@@ -454,6 +424,7 @@ describe('malformed replay and listener lifecycle', () => {
const session = Session.create(SessionId('bad-step'))
appendHeader(session, header('deepseek-v4-flash'))
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -464,7 +435,7 @@ describe('malformed replay and listener lifecycle', () => {
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
- }, { surfaceOp: 'append', sourceEventSeqs: [] })
+ }, { surfaceOp: 'append' })
expectRepeatedFailure(meter(), session, /no matching step\/start/)
})
@@ -474,6 +445,7 @@ describe('malformed replay and listener lifecycle', () => {
const session = Session.create(SessionId('bad-step-surface'))
appendHeader(session, header('deepseek-v4-flash'))
session.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -484,7 +456,7 @@ describe('malformed replay and listener lifecycle', () => {
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
- }, { surfaceOp: 'append', sourceEventSeqs: [] })
+ }, { surfaceOp: 'append' })
const service = meter()
const states = (service as unknown as {
states: WeakMap
@@ -509,6 +481,7 @@ describe('malformed replay and listener lifecycle', () => {
appendHeader(late, header('deepseek-v4-flash'))
late.append('step/end', { turn: 1, step: 1 })
late.append('assistant/message', {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -519,7 +492,7 @@ describe('malformed replay and listener lifecycle', () => {
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
- }, { surfaceOp: 'append', sourceEventSeqs: [] })
+ }, { surfaceOp: 'append' })
expectRepeatedFailure(
meter(),
late,
@@ -536,133 +509,33 @@ describe('malformed replay and listener lifecycle', () => {
)
})
- it('rejects invalid assistant source-event references', () => {
- const cases: Array<{
- name: string
- appendSource(session: Session): SessionSeqType[]
- pattern: RegExp
- }> = [
- {
- name: 'non-chunk',
- appendSource(session) {
- return [session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'x' }],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' }).seq]
- },
- pattern: /is not assistant\/chunk/,
- },
- {
- name: 'wrong-step',
- appendSource(session) {
- return [session.append('assistant/chunk', {
- turn: 1,
- step: 2,
- chunk: { type: 'finish', reason: { kind: 'stop' } },
- }).seq]
- },
- pattern: /belongs to another step/,
- },
- ]
- for (const testCase of cases) {
- const session = Session.create(SessionId(`bad-source-${testCase.name}`))
- session.append('step/start', { turn: 1, step: 1 })
- appendHeader(session, header('deepseek-v4-flash'))
- const sourceEventSeqs = testCase.appendSource(session)
- session.append('assistant/message', {
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'bad' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'deepseek-v4-flash' },
- },
- }),
- usage: { inputTokens: 1, outputTokens: 1 },
- }, { surfaceOp: 'append', sourceEventSeqs })
- expect(() => meter().measure(session)).toThrow(testCase.pattern)
- }
- })
-
- it('rejects repeated and non-earlier assistant source-event references', () => {
- const duplicate = Session.create(SessionId('duplicate-source'))
- duplicate.append('step/start', { turn: 1, step: 1 })
- appendHeader(duplicate, header('deepseek-v4-flash'))
- const source = duplicate.append('assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'finish', reason: { kind: 'stop' } },
- }).seq
- appendUnchecked(duplicate, {
- type: 'assistant/message',
- seq: SessionSeq(duplicate.seq),
- time: 0,
- data: {
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'deepseek-v4-flash' },
- },
- }),
- usage: { inputTokens: 1, outputTokens: 0 },
- },
- surfaceOp: 'append',
- sourceEventSeqs: [source, source],
- })
- expect(() => meter().measure(duplicate)).toThrow(/repeats source seq/)
-
- const future = Session.create(SessionId('future-source'))
- future.append('step/start', { turn: 1, step: 1 })
- appendHeader(future, header('deepseek-v4-flash'))
- appendUnchecked(future, {
- type: 'assistant/message',
- seq: SessionSeq(future.seq),
- time: 0,
- data: {
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'deepseek-v4-flash' },
- },
- }),
- usage: { inputTokens: 1, outputTokens: 0 },
- },
- surfaceOp: 'append',
- sourceEventSeqs: [SessionSeq(99)],
- })
- expect(() => meter().measure(future)).toThrow(/is not earlier/)
- })
-
it('does not partially apply a malformed assistant replacement', () => {
const session = Session.create(SessionId('transactional-replace'))
- session.append('user/message', createUserMessage({
+ const head = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'head' }],
source: { kind: 'user' },
- }), { surfaceOp: 'append' })
+ }), { surfaceOp: 'append' }).seq
appendHeader(session, header('deepseek-v4-flash'))
- const head = session.snapshotEvents()[0]!.seq
- session.append('assistant/message', {
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'replacement' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'deepseek-v4-flash' },
- },
- }),
- }, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] })
+ appendUnchecked(session, {
+ type: 'assistant/message',
+ seq: SessionSeq(session.seq),
+ time: 0,
+ data: {
+ stream: [],
+ turn: 1,
+ step: 1,
+ message: createMessage({
+ role: 'assistant',
+ content: [{ type: 'text', text: 'replacement' }],
+ source: {
+ kind: 'model',
+ ...{ provider: 'mock', model: 'deepseek-v4-flash' },
+ },
+ }),
+ },
+ surfaceOp: { op: 'replace', start: head, end: head },
+ sourceEventSeqs: [head],
+ })
expectRepeatedFailure(
meter(),
session,
diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts
index 3ffee99021..93cf23ff28 100644
--- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts
+++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts
@@ -39,10 +39,10 @@ function usageChunk(
turn: number,
step: number,
): SessionSeq {
- return session.append('assistant/chunk', {
+ return session.append('assistant/attempt', {
turn,
step,
- chunk: { type: 'usage', usage },
+ stream: [{ type: 'chunk', time: 0, chunk: { type: 'usage', usage } }],
}).seq
}
@@ -51,9 +51,9 @@ function finalUsage(
usage: TokenUsage,
turn: number,
step: number,
- sourceSeqs: SessionSeq[],
): void {
session.append('assistant/message', {
+ stream: [{ type: 'chunk', time: 0, chunk: { type: 'usage', usage } }],
turn,
step,
message: createMessage({
@@ -62,7 +62,7 @@ function finalUsage(
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage,
- }, { surfaceOp: 'append', sourceEventSeqs: sourceSeqs })
+ }, { surfaceOp: 'append' })
session.append('step/end', { turn, step })
}
@@ -120,8 +120,8 @@ describe('tokenUsage session projection', () => {
reasoningTokens: 3,
}
startStep(session, 1, 1)
- const source = usageChunk(session, usage, 1, 1)
- finalUsage(session, usage, 1, 1, [source])
+ usageChunk(session, usage, 1, 1)
+ finalUsage(session, usage, 1, 1)
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 10,
@@ -135,7 +135,7 @@ describe('tokenUsage session projection', () => {
it('replaces an earlier same-step chunk sample with the final usage', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
- const source = usageChunk(session, {
+ usageChunk(session, {
inputTokens: 10,
outputTokens: 2,
cacheReadTokens: 3,
@@ -145,7 +145,7 @@ describe('tokenUsage session projection', () => {
outputTokens: 5,
cacheReadTokens: 8,
cacheWriteTokens: 1,
- }, 1, 1, [source])
+ }, 1, 1)
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 14,
@@ -165,13 +165,17 @@ describe('tokenUsage session projection', () => {
outputTokens: 2,
cacheReadTokens: 3,
}, 1, 1)
- session.append('assistant/chunk', {
+ session.append('assistant/attempt', {
turn: 1,
step: 1,
- chunk: {
- type: 'finish',
- reason: { kind: 'error', failure: { code: 'RATE_LIMIT', message: 'busy', status: 429 } },
- },
+ stream: [{
+ type: 'chunk',
+ time: 1,
+ chunk: {
+ type: 'finish',
+ reason: { kind: 'error', failure: { code: 'RATE_LIMIT', message: 'busy', status: 429 } },
+ },
+ }],
})
session.append('llm/retry', {
retryId,
@@ -186,7 +190,7 @@ describe('tokenUsage session projection', () => {
failure: { code: 'RATE_LIMIT', message: 'busy', status: 429 },
})
session.append('llm/retry-started', { retryId, turn: 1, step: 1, retry: 1 })
- const second = usageChunk(session, {
+ usageChunk(session, {
inputTokens: 12,
outputTokens: 4,
cacheReadTokens: 6,
@@ -196,7 +200,7 @@ describe('tokenUsage session projection', () => {
outputTokens: 5,
cacheReadTokens: 8,
cacheWriteTokens: 1,
- }, 1, 1, [second])
+ }, 1, 1)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(projected(ctx, session)).toEqual({
@@ -210,7 +214,7 @@ describe('tokenUsage session projection', () => {
it('accumulates disjoint buckets across steps without adding reasoning twice', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
- const first = usageChunk(session, {
+ usageChunk(session, {
inputTokens: 10,
outputTokens: 6,
reasoningTokens: 5,
@@ -221,9 +225,9 @@ describe('tokenUsage session projection', () => {
outputTokens: 6,
reasoningTokens: 5,
cacheReadTokens: 2,
- }, 1, 1, [first])
+ }, 1, 1)
startStep(session, 1, 2)
- const second = usageChunk(session, {
+ usageChunk(session, {
inputTokens: 20,
outputTokens: 9,
reasoningTokens: 7,
@@ -234,7 +238,7 @@ describe('tokenUsage session projection', () => {
outputTokens: 9,
reasoningTokens: 7,
cacheWriteTokens: 4,
- }, 1, 2, [second])
+ }, 1, 2)
expect(projected(ctx, session)).toEqual({
uncachedInputTokens: 30,
@@ -260,8 +264,8 @@ describe('tokenUsage session projection', () => {
it('does not erase historical billing when the visible surface is replaced', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
- const source = usageChunk(session, { inputTokens: 12, outputTokens: 3 }, 1, 1)
- finalUsage(session, { inputTokens: 12, outputTokens: 3 }, 1, 1, [source])
+ usageChunk(session, { inputTokens: 12, outputTokens: 3 }, 1, 1)
+ finalUsage(session, { inputTokens: 12, outputTokens: 3 }, 1, 1)
const before = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'before compaction' }],
source: { kind: 'user' },
@@ -335,6 +339,7 @@ function appendAssistant(
step: number,
): SessionSeq {
return session.append('assistant/message', {
+ stream: [],
turn,
step,
message: createMessage({
@@ -343,7 +348,7 @@ function appendAssistant(
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage,
- }, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
+ }, { surfaceOp: 'append' }).seq
}
describe('contextPressure session projection', () => {
@@ -376,8 +381,8 @@ describe('contextPressure session projection', () => {
it('replaces pressure with the newest request rather than accumulating', async () => {
const { ctx, session } = await harness()
startStep(session, 1, 1)
- const first = usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
- finalUsage(session, { inputTokens: 100, outputTokens: 10 }, 1, 1, [first])
+ usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
+ finalUsage(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
startStep(session, 2, 1)
usageChunk(session, { inputTokens: 250, outputTokens: 10 }, 2, 1)
expect(pressure(ctx, session).pressureTokens).toBe(250)
diff --git a/packages/llm/token-meter/tests/turn-usage.spec.ts b/packages/llm/token-meter/tests/turn-usage.spec.ts
index bebc2e4434..ad3b648df3 100644
--- a/packages/llm/token-meter/tests/turn-usage.spec.ts
+++ b/packages/llm/token-meter/tests/turn-usage.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import type { TokenUsage } from '@deepseek-ai/dsh-llm'
+import type { StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { deriveTurnTokenUsage } from '../src/turn-usage.ts'
@@ -26,10 +26,17 @@ function message(
provider = 'deepseek',
model = 'deepseek-chat',
step = 1,
+ streamTokenUsage = tokenUsage,
) {
return event(seq, 'assistant/message', {
turn: 1,
step,
+ stream: [
+ { type: 'chunk', time: seq, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
+ ...(streamTokenUsage === undefined
+ ? []
+ : [{ type: 'chunk' as const, time: seq, chunk: { type: 'usage' as const, usage: streamTokenUsage } }]),
+ ],
message: {
id: `message-${seq}`,
role: 'assistant',
@@ -40,6 +47,14 @@ function message(
})
}
+function attempt(seq: number, chunks: readonly StreamChunk[], step = 1): SessionEvent {
+ return event(seq, 'assistant/attempt', {
+ turn: 1,
+ step,
+ stream: chunks.map((chunk, index) => ({ type: 'chunk', time: seq + index, chunk })),
+ })
+}
+
function completeAttempt(...middle: readonly SessionEvent[]): SessionEvent[] {
return [
event(1, 'turn/start', { turn: 1 }),
@@ -83,28 +98,31 @@ describe('deriveTurnTokenUsage', () => {
it('lets final message usage replace the latest streaming sample', () => {
const result = deriveTurnTokenUsage(completeAttempt(
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
- message(4, usage({ inputTokens: 30, outputTokens: 5, totalTokens: 45, cacheReadTokens: 10 })),
+ message(
+ 4,
+ usage({ inputTokens: 30, outputTokens: 5, totalTokens: 45, cacheReadTokens: 10 }),
+ 'deepseek',
+ 'deepseek-chat',
+ 1,
+ usage(),
+ ),
))
expect(result).toMatchObject({ uncachedInputTokens: 30, outputTokens: 5, totalTokens: 45 })
})
it('keeps the latest streaming sample when the final message omits usage', () => {
const result = deriveTurnTokenUsage(completeAttempt(
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
- message(4),
+ message(4, undefined, 'deepseek', 'deepseek-chat', 1, usage()),
))
expect(result).toMatchObject({ uncachedInputTokens: 100, outputTokens: 20, totalTokens: 170 })
})
it('counts an error-finished attempt once across its retry boundary', () => {
const events = completeAttempt(
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
- event(4, 'assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
- }),
+ attempt(3, [
+ { type: 'usage', usage: usage() },
+ { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
+ ]),
event(5, 'llm/retry', { turn: 1, step: 1 }),
event(6, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
message(7, usage({ inputTokens: 40, outputTokens: 10, totalTokens: 70, cacheReadTokens: 20 })),
@@ -119,7 +137,10 @@ describe('deriveTurnTokenUsage', () => {
it('does not invent an attempt for a scheduled retry that never started', () => {
const result = deriveTurnTokenUsage(completeAttempt(
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+ attempt(3, [
+ { type: 'usage', usage: usage() },
+ { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
+ ]),
event(4, 'llm/retry', { turn: 1, step: 1 }),
))
expect(result).toMatchObject({ totalTokens: 170 })
@@ -258,24 +279,20 @@ describe('deriveTurnTokenUsage', () => {
it('closes a sampled attempt at step/end', () => {
expect(deriveTurnTokenUsage(completeAttempt(
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
- event(4, 'assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'finish', reason: { kind: 'stop' } },
- }),
+ attempt(3, [
+ { type: 'usage', usage: usage() },
+ { type: 'finish', reason: { kind: 'stop' } },
+ ]),
event(5, 'tool/call', { turn: 1, step: 1 }),
))).toMatchObject({ totalTokens: 170 })
})
it('accepts an aborted finish after observing usage', () => {
expect(deriveTurnTokenUsage(completeAttempt(
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
- event(4, 'assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'finish', reason: { kind: 'aborted' } },
- }),
+ attempt(3, [
+ { type: 'usage', usage: usage() },
+ { type: 'finish', reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } } },
+ ]),
))).toMatchObject({ totalTokens: 170 })
})
@@ -329,27 +346,28 @@ describe('deriveTurnTokenUsage', () => {
['retry start for the wrong step', [
event(1, 'turn/start', { turn: 1 }),
event(2, 'step/start', { turn: 1, step: 1 }),
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+ attempt(3, [
+ { type: 'usage', usage: usage() },
+ { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
+ ]),
event(4, 'llm/retry', { turn: 1, step: 1 }),
event(5, 'llm/retry-started', { turn: 1, step: 2, retry: 1 }),
]],
- ['usage chunk outside an attempt', [
+ ['attempt outside a step', [
event(1, 'turn/start', { turn: 1 }),
- event(2, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+ attempt(2, [{ type: 'usage', usage: usage() }]),
]],
- ['usage chunk for the wrong step', [
+ ['attempt for the wrong step', [
event(1, 'turn/start', { turn: 1 }),
event(2, 'step/start', { turn: 1, step: 1 }),
- event(3, 'assistant/chunk', { turn: 1, step: 2, chunk: { type: 'usage', usage: usage() } }),
+ attempt(3, [{ type: 'usage', usage: usage() }], 2),
]],
['error finish without usage', [
event(1, 'turn/start', { turn: 1 }),
event(2, 'step/start', { turn: 1, step: 1 }),
- event(3, 'assistant/chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
- }),
+ attempt(3, [{
+ type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } },
+ }]),
]],
['retry outside an attempt', [
event(1, 'turn/start', { turn: 1 }),
@@ -358,7 +376,10 @@ describe('deriveTurnTokenUsage', () => {
['retry for the wrong step', [
event(1, 'turn/start', { turn: 1 }),
event(2, 'step/start', { turn: 1, step: 1 }),
- event(3, 'assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', usage: usage() } }),
+ attempt(3, [
+ { type: 'usage', usage: usage() },
+ { type: 'finish', reason: { kind: 'error', failure: { code: 'HTTP', message: 'failed' } } },
+ ]),
event(4, 'llm/retry', { turn: 1, step: 2 }),
]],
['retry after a final message', [
diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts
index cb090df239..6f0d1b5ddf 100644
--- a/packages/sdk/client/tests/fake-runtime.ts
+++ b/packages/sdk/client/tests/fake-runtime.ts
@@ -29,18 +29,18 @@
* (`1`), an aborted reason without its cause (`aborted`), an unknown abort
* cause (`abort-unknown`), a hook cause without its reason (`hook`), or no
* data member (`no-data`) for wire-validation probes.
- * - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty
- * assistant/message for a usage-only max-tokens step.
+ * - `FAKE_EMPTY_MESSAGE`: record an empty assistant/message whose embedded
+ * stream contains only usage and max-tokens settlement.
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
* cancel-during-handshake window).
* - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests).
- * - `FAKE_EXIT_DURING_PROMPT`: stream one partial chunk, then exit 17 while
- * the owned session run is waiting for its terminal state.
- * - `FAKE_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then
- * answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive
- * before the protocol failure (partial-output retention probe).
+ * - `FAKE_EXIT_DURING_PROMPT`: commit one interrupted assistant message, then
+ * exit 17 while the owned session run is waiting for its terminal state.
+ * - `FAKE_STREAM_THEN_MALFORMED`: commit a partial assistant attempt for the
+ * prompt, then answer `{}` (no accepted) — same-pipe ordering makes the
+ * attempt arrive before the protocol failure (partial-output retention probe).
* - `FAKE_IGNORE_EOF` + `FAKE_SIGTERM_FILE`: keep running after stdin EOF; touch the file on SIGTERM (ladder probe).
* - `FAKE_TRAP_SIGTERM`: with `FAKE_IGNORE_EOF`, survive SIGTERM too (SIGKILL-rung probe).
* - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe).
@@ -93,6 +93,26 @@ function assistantText(): string {
return parts.join('\n')
}
+function textStream(text: string): object[] {
+ return [
+ { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
+ { type: 'text-chunks', time0: 0, index: 0, dt: [], texts: [text] },
+ { type: 'chunk', time: 0, chunk: { type: 'block-end', index: 0, block: { type: 'text', text } } },
+ { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
+ ]
+}
+
+function usageOnlyStream(): object[] {
+ return [
+ {
+ type: 'chunk',
+ time: 0,
+ chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 0, totalTokens: 1 } },
+ },
+ { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'max-tokens' } } },
+ ]
+}
+
function runTurn(sessionId: string): void {
const text = assistantText()
if (env.FAKE_MALFORMED_EVENT !== undefined) {
@@ -100,8 +120,12 @@ function runTurn(sessionId: string): void {
return
}
event(sessionId, 'turn/start', { turn: 0 })
- event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
if (env.FAKE_MALFORMED_MESSAGE !== undefined) {
+ event(sessionId, 'assistant/attempt', {
+ turn: 0,
+ step: 0,
+ stream: textStream(text),
+ })
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
@@ -111,6 +135,7 @@ function runTurn(sessionId: string): void {
content: 'not-an-array',
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
+ stream: textStream(text),
})
return
}
@@ -118,6 +143,13 @@ function runTurn(sessionId: string): void {
notify('session.event', { sessionId, event: { type: 'assistant/message', seq: seq++, time: 0 } })
return
}
+ if (env.FAKE_EMPTY_MESSAGE !== undefined) {
+ event(sessionId, 'assistant/attempt', {
+ turn: 0,
+ step: 0,
+ stream: textStream(text),
+ })
+ }
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
@@ -129,6 +161,7 @@ function runTurn(sessionId: string): void {
content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
+ stream: env.FAKE_EMPTY_MESSAGE !== undefined ? usageOnlyStream() : textStream(text),
})
const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
if (reasonKind !== 'none') {
@@ -162,8 +195,13 @@ function runTurn(sessionId: string): void {
event(childId, 'assistant/message', {
turn: 0,
step: 0,
- content: [{ type: 'text', text: 'child says hi' }],
- provenance: { provider: 'fake', model: 'fake' },
+ message: {
+ id: `fake-child-${seq}`,
+ role: 'assistant',
+ content: [{ type: 'text', text: 'child says hi' }],
+ source: { kind: 'model', provider: 'fake', model: 'fake' },
+ },
+ stream: textStream('child says hi'),
})
notify('subagent.finished', {
provider: 'spawn',
@@ -237,18 +275,17 @@ reader.on('line', (line) => {
})
notify('session.status', { sessionId, status: 'running' })
if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
- event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
+ event(sessionId, 'assistant/attempt', {
+ turn: 0,
+ step: 0,
+ stream: textStream('streamed then cut short'),
+ })
respond({})
return
}
if (env.FAKE_EXIT_DURING_PROMPT !== undefined) {
const partial = env.FAKE_TEXT ?? 'partial before exit'
respond({ messageId })
- event(sessionId, 'assistant/chunk', {
- turn: 0,
- step: 0,
- chunk: { type: 'text-delta', index: 0, text: partial },
- })
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
@@ -258,6 +295,8 @@ reader.on('line', (line) => {
content: [{ type: 'text', text: partial }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
},
+ stream: textStream(partial),
+ interrupted: true,
})
setImmediate(() => { process.exit(17) })
return
diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts
index 164f0083bb..e6ba0f0374 100644
--- a/packages/sdk/client/tests/sdk-client.spec.ts
+++ b/packages/sdk/client/tests/sdk-client.spec.ts
@@ -126,7 +126,18 @@ describe('DeepSeekHarness', () => {
const first = await harness.run('say hi')
expect(first.finalResponse).toBe('turn answer')
expect(first.events.map(event => event.type)).toEqual([
- 'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end',
+ 'agent/inbox/spliced', 'turn/start', 'assistant/message', 'turn/end',
+ ])
+ const message = first.events.find(event => event.type === 'assistant/message')
+ expect(message?.data.stream).toEqual([
+ { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
+ { type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['turn answer'] },
+ {
+ type: 'chunk',
+ time: 0,
+ chunk: { type: 'block-end', index: 0, block: { type: 'text', text: 'turn answer' } },
+ },
+ { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
])
// Same subprocess, second session: ids differ, protocol state is reusable.
diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
index adabb8f9ca..63a5a09f37 100644
--- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
+++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
@@ -430,6 +430,7 @@ describe('SQLite session search', () => {
session.append(
'assistant/message',
{
+ stream: [],
turn: 1,
step: 1,
message: createAssistantMessage({
@@ -461,7 +462,16 @@ describe('SQLite session search', () => {
{ type: 'user/message', seq: SessionSeq(0), time: 10, data: createUserMessage({
content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
- { type: 'assistant/chunk', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } },
+ {
+ type: 'assistant/attempt',
+ seq: SessionSeq(1),
+ time: 11,
+ data: {
+ turn: 1,
+ step: 1,
+ stream: [{ type: 'text-chunks', time0: 11, index: 0, dt: [], texts: ['needle raw'] }],
+ },
+ },
{ type: 'user/message', seq: SessionSeq(2), time: 12, data: createUserMessage({
content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' },
}), surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) }, sourceEventSeqs: [SessionSeq(0)] },
@@ -2026,12 +2036,12 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
.toBe(SESSION_FORMAT_VERSION)
expect((await readdir(dirname(v0Path))).sort()).toEqual([
'session.jsonl',
- 'session.v1.jsonl',
+ 'session.v2.jsonl',
])
await expect(ctx.sessionPersistence.readRaw(meta.id)).resolves.toMatchObject({
meta: { ...meta, version: SESSION_FORMAT_VERSION },
- filename: 'session.v1.jsonl',
+ filename: 'session.v2.jsonl',
content: current,
})
expect(await readFile(currentLocation.path, 'utf8')).toBe(current)
diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts
index 1efc83f251..f31bfb4c5f 100644
--- a/packages/session-query/session-query/src/extraction.ts
+++ b/packages/session-query/session-query/src/extraction.ts
@@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-tool-todo'
/**
* Extract searchable semantic text from one first-party session event.
*
- * Structural boundaries, raw stream chunks, request envelopes, and unknown
+ * Structural boundaries, embedded raw streams, request envelopes, and unknown
* declaration-merged events contribute no text.
* @param event - event to inspect.
* @returns newline-joined semantic text, or an empty string when non-searchable.
@@ -33,7 +33,7 @@ export function extractSessionEventText(event: SessionEvent): string {
case 'turn/start':
case 'step/start':
case 'step/end':
- case 'assistant/chunk':
+ case 'assistant/attempt':
case 'request/header':
return ''
// SessionEventMap is merge-extensible. Unknown events remain
diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts
index 0e528ec7f5..f28c706fa9 100644
--- a/packages/session-query/session-query/tests/search-helpers.spec.ts
+++ b/packages/session-query/session-query/tests/search-helpers.spec.ts
@@ -51,6 +51,7 @@ describe('session-query semantic extraction', () => {
content: messageContent, source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'assistant/message', seq: SessionSeq(1), time: 2, data: {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -103,6 +104,7 @@ describe('session-query semantic extraction', () => {
seq: SessionSeq(9),
time: 10,
data: {
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
@@ -137,7 +139,16 @@ describe('session-query semantic extraction', () => {
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: SessionSeq(1), time: 1, data: { turn: 1, step: 1 } },
{ type: 'step/end', seq: SessionSeq(2), time: 1, data: { turn: 1, step: 1 } },
- { type: 'assistant/chunk', seq: SessionSeq(3), time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
+ {
+ type: 'assistant/attempt',
+ seq: SessionSeq(3),
+ time: 1,
+ data: {
+ turn: 1,
+ step: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['raw'] }],
+ },
+ },
{ type: 'request/header', seq: SessionSeq(4), time: 1, data: { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' } },
{ type: 'future/event', seq: SessionSeq(5), time: 1, data: { text: 'hidden' } } as never,
]
@@ -150,18 +161,23 @@ describe('session-query document and filter helpers', () => {
{ type: 'user/message', seq: SessionSeq(0), time: 10, data: createUserMessage({
content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
- { type: 'assistant/chunk', seq: SessionSeq(1), time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
- { type: 'assistant/message', seq: SessionSeq(2), time: 12, data: {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
+ {
+ type: 'assistant/attempt',
+ seq: SessionSeq(1),
+ time: 11,
+ data: {
+ turn: 1,
+ step: 1,
+ stream: [{ type: 'text-chunks', time0: 11, index: 0, dt: [], texts: ['raw'] }],
+ },
+ },
+ { type: 'user/message', seq: SessionSeq(2), time: 12, data:
+ createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
+ source: { kind: 'plugin', plugin: 'test' },
}),
- }, surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) }, sourceEventSeqs: [SessionSeq(0)] },
+ surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) },
+ sourceEventSeqs: [SessionSeq(0)] },
{ type: 'turn/end', seq: SessionSeq(3), time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
]
@@ -229,20 +245,13 @@ describe('session-query document and filter helpers', () => {
{ kind: 'created-at', from: Number.NaN },
])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
const malformed: SessionEvent[] = [{
- type: 'assistant/message',
+ type: 'user/message',
seq: SessionSeq(0),
time: 1,
- data: {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'bad' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ data: createUserMessage({
+ content: [{ type: 'text', text: 'bad' }],
+ source: { kind: 'plugin', plugin: 'test' },
+ }),
surfaceOp: { op: 'replace', start: SessionSeq(9), end: SessionSeq(9) },
}]
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts
index c63afe3089..907104251b 100644
--- a/packages/session-query/session-query/tests/session-query.spec.ts
+++ b/packages/session-query/session-query/tests/session-query.spec.ts
@@ -958,24 +958,17 @@ describe('session-query exact reads', () => {
}),
{ surfaceOp: 'append' },
)
- session.append('assistant/chunk', {
+ session.append('assistant/attempt', {
turn: 1,
step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'draft' },
+ stream: [{ type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['draft'] }],
})
session.append(
- 'assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'replacement' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ 'user/message',
+ createUserMessage({
+ content: [{ type: 'text', text: 'replacement' }],
+ source: { kind: 'plugin', plugin: 'test' },
+ }),
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
@@ -993,10 +986,10 @@ describe('session-query exact reads', () => {
}),
{ surfaceOp: 'append' },
)
- session.append('assistant/chunk', {
+ session.append('assistant/attempt', {
turn: 1,
step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'draft' },
+ stream: [{ type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['draft'] }],
})
session.append(
'user/message',
@@ -1025,6 +1018,7 @@ describe('session-query exact reads', () => {
session.append(
'assistant/message',
{
+ stream: [],
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts
index 8769b9db39..90ca97488e 100644
--- a/packages/session-query/session-query/tests/tracing.spec.ts
+++ b/packages/session-query/session-query/tests/tracing.spec.ts
@@ -140,10 +140,10 @@ function expectCode(code: SessionQueryErrorCode): Error {
function appendTraceEvents(session: Session): void {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
- session.append('assistant/chunk', {
+ session.append('assistant/attempt', {
turn: 1,
step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'draft' },
+ stream: [{ type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['draft'] }],
})
session.append(
'user/message',
@@ -153,18 +153,11 @@ function appendTraceEvents(session: Session): void {
{ surfaceOp: 'append', sourceEventSeqs: [SessionSeq(2)] },
)
session.append(
- 'assistant/message',
- {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'summary one' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ 'user/message',
+ createUserMessage({
+ content: [{ type: 'text', text: 'summary one' }],
+ source: { kind: 'plugin', plugin: 'test' },
+ }),
{
surfaceOp: { op: 'replace', start: SessionSeq(3), end: SessionSeq(3) },
sourceEventSeqs: [SessionSeq(3), SessionSeq(2)],
@@ -180,18 +173,11 @@ function appendTraceEvents(session: Session): void {
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
session.append(
- 'assistant/message',
- {
- turn: 1, step: 2,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'summary two' }],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- },
+ 'user/message',
+ createUserMessage({
+ content: [{ type: 'text', text: 'summary two' }],
+ source: { kind: 'plugin', plugin: 'test' },
+ }),
{
surfaceOp: { op: 'replace', start: SessionSeq(4), end: SessionSeq(4) },
sourceEventSeqs: [SessionSeq(2), SessionSeq(4)],
@@ -425,6 +411,7 @@ describe('session event tracing', () => {
seq: SessionSeq(1),
time: 2,
data: {
+ stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
@@ -478,7 +465,13 @@ describe('session event tracing', () => {
{ ...appendEvent(SessionSeq(1)), surfaceOp: { op: 'replace', start: 0, end: 0 } },
]],
['replacement missing a shadowed source', [
- { type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } },
+ {
+ type: 'assistant/attempt', seq: 0, time: 1,
+ data: {
+ turn: 1, step: 1,
+ stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['draft'] }],
+ },
+ },
appendEvent(SessionSeq(1)),
{ ...appendEvent(SessionSeq(2), [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } },
]],
diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts
index f79c5a1b2e..27541d1ed0 100644
--- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts
+++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts
@@ -1992,19 +1992,11 @@ describe('trace and exact read rendering', () => {
{ surfaceOp: 'append' },
)
session.append(
- 'assistant/message',
- {
- turn: 1,
- step: 1,
- message: createMessage({
- role: 'assistant',
- content: [{ type: 'text', text: 'replacement' }],
- source: {
- kind: 'model',
- ...{ provider: 'test', model: 'test' },
- },
- }),
- },
+ 'user/message',
+ createUserMessage({
+ content: [{ type: 'text', text: 'replacement' }],
+ source: { kind: 'plugin', plugin: 'test' },
+ }),
{
surfaceOp: { op: 'replace', start: SessionSeq(0), end: SessionSeq(0) },
sourceEventSeqs: [SessionSeq(0)],
@@ -2030,6 +2022,7 @@ describe('trace and exact read rendering', () => {
session.append(
'assistant/message',
{
+ stream: [],
turn: 1,
step: 1,
message: createMessage({
diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml
index 6925184d6a..4f81cdcb2c 100644
--- a/packages/session/README.i18n.yaml
+++ b/packages/session/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/README.md
-README.md: b73c1519bc03271fbcc66f2233dd5e3a91dd1902
-README.zh.md: d74b36f4c29480c9331c7f134b2c839b98bc2e4e
+README.md: 1afce7df69a7c87e0b27488af17c9450016fc906
+README.zh.md: 57a9b06c44339167fc692e735ed8cee0941324a2
diff --git a/packages/session/README.md b/packages/session/README.md
index b73c1519bc..1afce7df69 100644
--- a/packages/session/README.md
+++ b/packages/session/README.md
@@ -30,6 +30,7 @@ The group splits into four families: durable storage (persistence seam, backends
|---|---|---|
| [`session-format/`](session-format/README.md) | Pure adjacent-format chain and artifact validation library | library — no ctx key |
| [`session-format-v0-to-v1/`](session-format-v0-to-v1/README.md) | Frozen released-v0 decoder and identity migration into released v1 | library — no ctx key |
+| [`session-format-v1-to-v2/`](session-format-v1-to-v2/README.md) | Frozen released-v1 decoder and cardinality-changing Assistant-stream migration into released v2 | library — no ctx key |
| [`session-format-catalog/`](session-format-catalog/README.md) | Generated static catalog of shipped adjacent migrations | library — no ctx key |
| [`session-persistence/`](session-persistence/README.md) | Defines the durable session-storage service and the shared write coordination every backend composes | `ctx.sessionPersistence` |
| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | Shipped backend: immutable canonical generation filenames per Session with exclusive successor publication, optionally Zstandard-compressed | registers on `ctx.sessionPersistence` |
diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md
index d74b36f4c2..57a9b06c44 100644
--- a/packages/session/README.zh.md
+++ b/packages/session/README.zh.md
@@ -30,6 +30,7 @@ session 组让 agent(智能体)的对话在实时 loop 之外持久可复用
|---|---|---|
| [`session-format/`](session-format/README.zh.md) | 纯相邻格式链与产物校验库 | 库,不使用 ctx key |
| [`session-format-v0-to-v1/`](session-format-v0-to-v1/README.zh.md) | 冻结的 released-v0 解码器,以及到 released v1 的恒等迁移 | 库,不使用 ctx key |
+| [`session-format-v1-to-v2/`](session-format-v1-to-v2/README.zh.md) | 冻结的 released-v1 解码器,以及把 Assistant 流嵌入 released v2 的基数变化迁移 | 库,不使用 ctx key |
| [`session-format-catalog/`](session-format-catalog/README.zh.md) | 已交付相邻迁移的生成式静态目录 | 库,不使用 ctx key |
| [`session-persistence/`](session-persistence/README.zh.md) | 定义持久会话存储服务,以及每个后端组合的共享写入协调机制 | `ctx.sessionPersistence` |
| [`session-persistence-jsonl/`](session-persistence-jsonl/README.zh.md) | 随产品交付的后端:逐 Session 使用不可变规范 generation 文件名并排他发布后继;可选 Zstandard 压缩 | 注册到 `ctx.sessionPersistence` |
diff --git a/packages/session/session-checkpoint-policy/README.i18n.yaml b/packages/session/session-checkpoint-policy/README.i18n.yaml
index 27e7ad16ee..2c2cdc4de9 100644
--- a/packages/session/session-checkpoint-policy/README.i18n.yaml
+++ b/packages/session/session-checkpoint-policy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-checkpoint-policy/README.md
-README.md: 76ee46a791123908815cb34a3fa0602c8e40645b
-README.zh.md: 2302c7b70a66c050b430cb74cfd9bd115cdc6b79
+README.md: 3613bb76ed218693e4ceb95ba2e20724bebe83aa
+README.zh.md: 220c14a0a486508d2943bd8c8419ae82a2d5b176
diff --git a/packages/session/session-checkpoint-policy/README.md b/packages/session/session-checkpoint-policy/README.md
index 76ee46a791..3613bb76ed 100644
--- a/packages/session/session-checkpoint-policy/README.md
+++ b/packages/session/session-checkpoint-policy/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-session-checkpoint-policy` is a zero-config plugin that makes a persisted session durable at the moments that matter: before a model request reaches the adapter, before a top-level tool body can produce an external side effect, and at each step boundary so the preceding response and tool results are stored before the next request. Load it beside one persistence backend, and a crash after any checkpoint resumes with the recorded work — a request, a tool call, or a completed step — instead of losing it. The policy adds no prompt, tool schema, or configuration; checkpoint failures are fail-closed, so neither the adapter nor a top-level tool body runs when the durable write cannot be confirmed. Streaming `assistant/chunk` events get no per-chunk checkpoint, and a persisted call without a result records an unknown outcome rather than retrying automatically.
+`dsh-session-checkpoint-policy` is a zero-config plugin that makes a persisted session durable at the moments that matter: before a model request reaches the adapter, before a top-level tool body can produce an external side effect, and at each step boundary so the preceding response and tool results are stored before the next request. Load it beside one persistence backend, and a crash after any checkpoint resumes with the recorded work — a request, a tool call, or a completed step — instead of losing it. The policy adds no prompt, tool schema, or configuration; checkpoint failures are fail-closed, so neither the adapter nor a top-level tool body runs when the durable write cannot be confirmed. Live Assistant frames are transient until one `assistant/message` or `assistant/attempt` settlement commits the compact stream, and a persisted call without a result records an unknown outcome rather than retrying automatically.
## Table of Contents
@@ -113,7 +113,7 @@ The repair result is appended after the reusable prefix, so it does not invalida
These limits define where the policy's durability guarantee stops. They are current package constraints, not a task backlog.
- **Durable execution intent, not exactly-once effects** — the policy records that a call was dispatched, not that its external effect completed. Side-effecting tools should forward `exec.callId` as an idempotency key when their provider supports one.
-- **No per-chunk checkpoint for streaming** — `assistant/chunk` events rely on bounded background batches; a hard crash may lose the current in-memory batch or outstanding write.
+- **No checkpoint inside an active model attempt** — a hard crash may lose transient Assistant frames that have not reached their durable `assistant/message` or `assistant/attempt` settlement.
- **Unknown outcome, not automatic retry** — a persisted call without a result cannot prove whether its external effect completed, so recovery records an unknown outcome instead of retrying.
diff --git a/packages/session/session-checkpoint-policy/README.zh.md b/packages/session/session-checkpoint-policy/README.zh.md
index 2302c7b70a..220c14a0a4 100644
--- a/packages/session/session-checkpoint-policy/README.zh.md
+++ b/packages/session/session-checkpoint-policy/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-session-checkpoint-policy` 是一个零配置插件,让持久化会话在关键时刻变得持久:模型请求到达适配器之前、顶层工具正文可能产生外部副作用之前,以及每个步骤边界——使前一响应与工具结果在下一个请求前已存储。把它与一个持久化后端一起加载后,任何检查点之后的崩溃都能恢复已记录的工作——请求、工具调用或已完成步骤——而不会丢失。该策略不添加提示词、工具 schema 或配置;检查点失败按失败即阻止原则处理,因此在无法确认持久写入时,适配器与顶层工具正文都不会运行。流式 `assistant/chunk` 事件没有逐分片检查点,而没有结果的持久调用会记录为未知结果,而不是自动重试。
+`dsh-session-checkpoint-policy` 是一个零配置插件,让持久化会话在关键时刻变得持久:模型请求到达适配器之前、顶层工具正文可能产生外部副作用之前,以及每个步骤边界——使前一响应与工具结果在下一个请求前已存储。把它与一个持久化后端一起加载后,任何检查点之后的崩溃都能恢复已记录的工作——请求、工具调用或已完成步骤——而不会丢失。该策略不添加提示词、工具 schema 或配置;检查点失败按失败即阻止原则处理,因此在无法确认持久写入时,适配器与顶层工具正文都不会运行。实时 Assistant frame 在一个 `assistant/message` 或 `assistant/attempt` settlement 提交紧凑 stream 前保持瞬态,而没有结果的持久调用会记录为未知结果,而不是自动重试。
## 目录
@@ -113,7 +113,7 @@ kind: "package-reference"
这些限制界定本策略持久性保证的终点。它们是当前包约束,不是任务积压。
- **持久记录执行意图,而非恰好一次副作用**——策略记录的是调用已分派,而非其外部副作用已完成。当提供方支持时,有副作用的工具应将 `exec.callId` 作为幂等键转发。
-- **流式内容没有逐分片检查点**——`assistant/chunk` 事件依赖有界后台批次;硬崩溃可能丢失当前内存批次或尚未完成的写入。
+- **活跃模型 attempt 内没有检查点**——硬崩溃可能丢失尚未进入持久 `assistant/message` 或 `assistant/attempt` settlement 的瞬态 Assistant frame。
- **记录未知结果,而非自动重试**——没有结果的持久调用无法证明其外部副作用是否完成,因此恢复记录未知结果,而不是自动重试。
diff --git a/packages/session/session-format-catalog/README.i18n.yaml b/packages/session/session-format-catalog/README.i18n.yaml
index 034e237802..b2746fd60b 100644
--- a/packages/session/session-format-catalog/README.i18n.yaml
+++ b/packages/session/session-format-catalog/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-format-catalog/README.md
-README.md: 5e138e8e1fabe7a933e9c72189d12a2f3c50f479
-README.zh.md: 72b59e0398aba5c55acf9bef252b577e87d8ae39
+README.md: 120a770ba5624d5ecde040d94399d21dcf915bd9
+README.zh.md: 5c0a7a816d637ea08e84a82f129c1387762d6299
diff --git a/packages/session/session-format-catalog/README.md b/packages/session/session-format-catalog/README.md
index 5e138e8e1f..120a770ba5 100644
--- a/packages/session/session-format-catalog/README.md
+++ b/packages/session/session-format-catalog/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-session-format-catalog` gives persistence one deterministic Session format reader without consulting mounted plugins. It assembles the frozen v0 and v1 codecs with the single v0-to-v1 edge, checks the complete gap-free chain at module initialization, and exposes physical dispatch, header-only classification, migration, and current encoding through `sessionFormatCatalog`.
+`dsh-session-format-catalog` gives persistence one deterministic Session format reader without consulting mounted plugins. It assembles the frozen v0, v1, and v2 codecs with the adjacent v0-to-v1 and v1-to-v2 edges, checks the complete gap-free chain at module initialization, and exposes physical dispatch, header-only classification, migration, and current encoding through `sessionFormatCatalog`.
## Table of Contents
@@ -59,6 +59,7 @@ The catalog contains all supported historical readers directly. A profile cannot
- [Migration machinery](../session-format/README.md) — catalog construction and dispatch behavior.
- [Released v0 to v1 edge](../session-format-v0-to-v1/README.md) — codec and validator ownership.
+- [Released v1 to v2 edge](../session-format-v1-to-v2/README.md) — Assistant stream embedding and cardinality-changing reference remapping.
- [JSONL persistence](../session-persistence-jsonl/README.md) — immutable generation naming and exclusive publication.
-----
diff --git a/packages/session/session-format-catalog/README.zh.md b/packages/session/session-format-catalog/README.zh.md
index 72b59e0398..5c0a7a816d 100644
--- a/packages/session/session-format-catalog/README.zh.md
+++ b/packages/session/session-format-catalog/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-library"
## 概述
-`dsh-session-format-catalog` 为持久化提供一个确定性的 Session 格式读取器,且无需查询已挂载插件。它把冻结的 v0 和 v1 编解码器与唯一的 v0 到 v1 迁移边装配起来,在模块初始化时校验完整且无缺口的迁移链,并通过 `sessionFormatCatalog` 暴露物理分派、仅标头分类、迁移和当前格式编码。
+`dsh-session-format-catalog` 为持久化提供一个确定性的 Session 格式读取器,且无需查询已挂载插件。它把冻结的 v0、v1 与 v2 编解码器和相邻的 v0 到 v1、v1 到 v2 迁移边装配起来,在模块初始化时校验完整且无缺口的迁移链,并通过 `sessionFormatCatalog` 暴露物理分派、仅标头分类、迁移和当前格式编码。
## 目录
@@ -59,6 +59,7 @@ const current = sessionFormatCatalog.migrate(sessionFormatCatalog.decodeArtifact
- [迁移机制](../session-format/README.zh.md)——目录构造与分派行为。
- [已发布 v0 到 v1 迁移边](../session-format-v0-to-v1/README.zh.md)——编解码器与校验器所有权。
+- [已发布 v1 到 v2 迁移边](../session-format-v1-to-v2/README.zh.md)——Assistant stream 嵌入与基数变化引用重映射。
- [JSONL 持久化](../session-persistence-jsonl/README.zh.md)——不可变 generation 命名与排他发布。
-----
diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json
index 3385d53936..05ac4dbdde 100644
--- a/packages/session/session-format-catalog/package.json
+++ b/packages/session/session-format-catalog/package.json
@@ -28,7 +28,8 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-session-format": "workspace:^",
- "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^"
+ "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^",
+ "@deepseek-ai/dsh-session-format-v1-to-v2": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
diff --git a/packages/session/session-format-catalog/src/generated.ts b/packages/session/session-format-catalog/src/generated.ts
index e7b390483e..e8ba219e1b 100644
--- a/packages/session/session-format-catalog/src/generated.ts
+++ b/packages/session/session-format-catalog/src/generated.ts
@@ -6,20 +6,21 @@
import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
import { validateInstalledCurrentSessionArtifact, validateInstalledCurrentSessionHeader } from './current.ts'
-import { assertReleasedV1Header, releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, restoreReleasedV1Artifact, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
+import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
+import { assertReleasedV2Header, releasedV2SessionFormatCodec, restoreReleasedV2Artifact, sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */
export const sessionFormatCatalog = createSessionFormatCatalog({
- currentVersion: 1,
- codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec],
- migrations: [sessionFormatV0ToV1],
+ currentVersion: 2,
+ codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec],
+ migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2],
restoreCurrent(artifact) {
- const restored = restoreReleasedV1Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
+ const restored = restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)
validateInstalledCurrentSessionArtifact(restored)
return restored
},
restoreCurrentHeader(header) {
- assertReleasedV1Header(header)
+ assertReleasedV2Header(header)
validateInstalledCurrentSessionHeader(header)
return header
},
diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts
index c8a9cf336f..57deb015aa 100644
--- a/packages/session/session-format-catalog/tests/catalog.spec.ts
+++ b/packages/session/session-format-catalog/tests/catalog.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { sessionFormatCatalog } from '../src/index.ts'
describe('first-party Session format catalog', () => {
- it('statically owns the complete v0 to v1 chain', () => {
+ it('statically owns the complete adjacent v0 to v2 chain', () => {
const header = {
type: 'session',
version: 0,
@@ -12,13 +12,13 @@ describe('first-party Session format catalog', () => {
delegationDepth: 0,
}
- expect(sessionFormatCatalog.currentVersion).toBe(1)
+ expect(sessionFormatCatalog.currentVersion).toBe(2)
expect(sessionFormatCatalog.readHeader(header)).toEqual({
status: 'migration-required',
storedVersion: 0,
- targetVersion: 1,
+ targetVersion: 2,
header: {
- version: 1,
+ version: 2,
id: 'catalog',
createdAt: 1,
isSeeded: true,
@@ -26,32 +26,29 @@ describe('first-party Session format catalog', () => {
},
})
- const currentHeader = { ...header, version: 1 }
- const current = sessionFormatCatalog.decodeArtifact(currentHeader, [
+ const v1Header = { ...header, version: 1 }
+ const current = sessionFormatCatalog.decodeArtifact(v1Header, [
{ type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } },
])
expect(sessionFormatCatalog.migrate(current)).toMatchObject({
- header: { version: 1, id: 'catalog' },
+ header: { version: 2, id: 'catalog' },
})
})
- it('restores the installed current vocabulary without freezing ordinary payload additions', () => {
+ it('restores only the exact frozen current vocabulary and payloads', () => {
const header = {
- type: 'session', version: 1, id: 'current-growth', createdAt: 1, delegationDepth: 0,
+ type: 'session', version: 2, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
}
- const extended = sessionFormatCatalog.decodeArtifact(header, [{
+ expect(() => sessionFormatCatalog.decodeArtifact(header, [{
type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
- }])
- expect(sessionFormatCatalog.migrate(extended).events).toEqual(extended.events)
+ }])).toThrow(/unexpected field postReleaseMember/)
- const unknownRequired = sessionFormatCatalog.decodeArtifact(header, [{
+ expect(() => sessionFormatCatalog.decodeArtifact(header, [{
type: 'ordinary/not-installed', seq: 0, time: 1, data: 'future',
- }])
- expect(() => sessionFormatCatalog.migrate(unknownRequired)).toThrow(/unknown required event/)
+ }])).toThrow(/unknown event type/)
- const unknownIgnorable = sessionFormatCatalog.decodeArtifact(header, [{
+ expect(() => sessionFormatCatalog.decodeArtifact(header, [{
type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true,
- }])
- expect(sessionFormatCatalog.migrate(unknownIgnorable).events).toEqual(unknownIgnorable.events)
+ }])).toThrow(/unknown event type/)
})
})
diff --git a/packages/session/session-format-catalog/tests/current.spec.ts b/packages/session/session-format-catalog/tests/current.spec.ts
index 6b180f192a..eac4e264bb 100644
--- a/packages/session/session-format-catalog/tests/current.spec.ts
+++ b/packages/session/session-format-catalog/tests/current.spec.ts
@@ -6,7 +6,7 @@ import {
} from '../src/current.ts'
const currentHeader: SessionFormatHeader = {
- version: 1,
+ version: 2,
id: 'installed-current',
createdAt: 1,
isSeeded: false,
@@ -16,14 +16,14 @@ const currentHeader: SessionFormatHeader = {
describe('installed current Session restoration', () => {
it('rejects version skew before entering current Session validation', () => {
expect(() => { validateInstalledCurrentSessionHeader({ ...currentHeader, version: 0 }) })
- .toThrow(/installed Session format is v1, got v0/)
+ .toThrow(/installed Session format is v2, got v0/)
const artifact: SessionFormatArtifact = {
header: { ...currentHeader, version: 0 },
inheritedEventCount: 0,
events: [],
}
expect(() => { validateInstalledCurrentSessionArtifact(artifact) })
- .toThrow(/installed Session format is v1, got v0/)
+ .toThrow(/installed Session format is v2, got v0/)
})
it('accepts only current request-header reasons and the true starts-series marker', () => {
diff --git a/packages/session/session-format-catalog/tsconfig.json b/packages/session/session-format-catalog/tsconfig.json
index 0d32e33846..e15db98d36 100644
--- a/packages/session/session-format-catalog/tsconfig.json
+++ b/packages/session/session-format-catalog/tsconfig.json
@@ -22,6 +22,9 @@
},
{
"path": "../session-format-v0-to-v1"
+ },
+ {
+ "path": "../session-format-v1-to-v2"
}
]
}
diff --git a/packages/session/session-format-v0-to-v1/src/dispositions.ts b/packages/session/session-format-v0-to-v1/src/dispositions.ts
index 257c9c8d3d..47ef9070ba 100644
--- a/packages/session/session-format-v0-to-v1/src/dispositions.ts
+++ b/packages/session/session-format-v0-to-v1/src/dispositions.ts
@@ -6,7 +6,14 @@ export interface ReleasedV0PayloadDisposition {
readonly opaque: readonly string[]
}
-function disposition(
+/**
+ * Freeze one exact released payload-member disposition for adjacent format validators.
+ * @param required - members that must be present.
+ * @param optional - additional admitted members.
+ * @param opaque - members retained as lossless JSON without nested semantic inspection.
+ * @returns the detached frozen disposition.
+ */
+export function defineReleasedPayloadDisposition(
required: readonly string[],
optional: readonly string[] = [],
opaque: readonly string[] = [],
@@ -18,6 +25,8 @@ function disposition(
})
}
+const disposition = defineReleasedPayloadDisposition
+
/**
* Frozen released-v0 event and payload-member inventory.
* Every listed member is preserved by the identity edge; members in `opaque`
diff --git a/packages/session/session-format-v0-to-v1/src/index.ts b/packages/session/session-format-v0-to-v1/src/index.ts
index c432456806..5aa631ee82 100644
--- a/packages/session/session-format-v0-to-v1/src/index.ts
+++ b/packages/session/session-format-v0-to-v1/src/index.ts
@@ -3,7 +3,10 @@
export * from './codec.ts'
export * from './dispositions.ts'
export * from './migration.ts'
+export { assertReleasedPayloadSemantics } from './payload-validation.ts'
+export { assertReleasedArtifactRelationships } from './relationships.ts'
export {
+ assertReleasedSurfaceMetadata,
assertReleasedV1Artifact,
assertReleasedV1Header,
restoreReleasedV1Artifact,
diff --git a/packages/session/session-format-v0-to-v1/src/payload-validation.ts b/packages/session/session-format-v0-to-v1/src/payload-validation.ts
index e66e2f98d9..083c0b42c9 100644
--- a/packages/session/session-format-v0-to-v1/src/payload-validation.ts
+++ b/packages/session/session-format-v0-to-v1/src/payload-validation.ts
@@ -12,7 +12,7 @@ type JsonRecord = Record
* @param event - known event with exact top-level members.
* @param version - source or current payload generation.
*/
-export function assertReleasedPayloadSemantics(event: SessionFormatEvent, version: 0 | 1): void {
+export function assertReleasedPayloadSemantics(event: SessionFormatEvent, version: number): void {
const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`)
const label = `${event.type} ${event.seq}`
switch (event.type) {
@@ -419,13 +419,13 @@ function tokenUsageValue(value: SessionFormatJsonValue | undefined, label: strin
for (const key of Object.keys(usage)) countValue(usage[key], `${label} ${key}`)
}
-function contentBlocksValue(value: SessionFormatJsonValue | undefined, label: string, version: 0 | 1): void {
+function contentBlocksValue(value: SessionFormatJsonValue | undefined, label: string, version: number): void {
arrayValue(value, label, (member, memberLabel) => {
contentBlockValue(member, memberLabel, version)
})
}
-function contentBlockValue(value: SessionFormatJsonValue, label: string, version: 0 | 1): void {
+function contentBlockValue(value: SessionFormatJsonValue, label: string, version: number): void {
const block = releasedV0Record(value, label)
switch (block['type']) {
case 'text':
@@ -478,7 +478,7 @@ function imageAttachmentValue(value: SessionFormatJsonValue | undefined, label:
function messageValue(
value: SessionFormatJsonValue | undefined,
label: string,
- version: 0 | 1,
+ version: number,
expected?: 'user' | 'assistant' | 'tool',
): void {
const message = exactRecord(value, label, ['id', 'role', 'content', 'source'])
@@ -503,7 +503,7 @@ function messageValue(
function messageSourceValue(
value: SessionFormatJsonValue | undefined,
label: string,
- version: 0 | 1,
+ version: number,
expected?: 'user' | 'assistant' | 'tool',
): void {
const source = releasedV0Record(value, label)
@@ -618,7 +618,7 @@ function pluginSourceValue(source: JsonRecord, label: string): void {
else if (source['summary'] !== undefined) throw new SessionFormatError(`${label} summary requires notice form`)
}
-function sessionReferenceSourceValue(source: JsonRecord, label: string, version: 0 | 1): void {
+function sessionReferenceSourceValue(source: JsonRecord, label: string, version: number): void {
assertReleasedV0Keys(source, ['kind', 'form', 'version', 'references'], [], label)
literalValue(source['form'], ['recall'], `${label} form`)
literalValue(source['version'], [1], `${label} version`)
@@ -632,14 +632,19 @@ function sessionReferenceSourceValue(source: JsonRecord, label: string, version:
'sessionId', 'label', 'capturedThroughSeq', 'compacted', 'originalMessages',
'retainedMessages', 'omittedMessages', 'omittedBytes', 'truncated', 'inputIndex',
],
- version === 1 ? ['capturedFormatVersion'] : [],
+ version >= 1 ? ['capturedFormatVersion'] : [],
)
nonEmptyString(reference['sessionId'], `${memberLabel} sessionId`)
stringValue(reference['label'], `${memberLabel} label`)
if (reference['capturedThroughSeq'] !== null) countValue(reference['capturedThroughSeq'], `${memberLabel} capturedThroughSeq`)
- if (reference['capturedFormatVersion'] !== undefined
- && countValue(reference['capturedFormatVersion'], `${memberLabel} capturedFormatVersion`) !== 1) {
- throw new SessionFormatError(`${memberLabel} capturedFormatVersion must be 1`)
+ if (reference['capturedFormatVersion'] !== undefined) {
+ const capturedVersion = countValue(
+ reference['capturedFormatVersion'],
+ `${memberLabel} capturedFormatVersion`,
+ )
+ if (capturedVersion < 1 || capturedVersion > version) {
+ throw new SessionFormatError(`${memberLabel} capturedFormatVersion must be between 1 and ${version}`)
+ }
}
booleanValue(reference['compacted'], `${memberLabel} compacted`)
const original = countValue(reference['originalMessages'], `${memberLabel} originalMessages`)
@@ -984,7 +989,7 @@ function teamTaskValue(value: SessionFormatJsonValue | undefined, label: string)
arrayValue(task['writeScopes'], `${label} writeScopes`, stringValue)
}
-function teamMessageValue(value: SessionFormatJsonValue | undefined, label: string, version: 0 | 1): void {
+function teamMessageValue(value: SessionFormatJsonValue | undefined, label: string, version: number): void {
const message = exactRecord(value, label, ['id', 'senderId', 'senderName', 'targetId', 'delivery', 'content'])
for (const key of ['id', 'senderId', 'targetId'] as const) nonEmptyString(message[key], `${label} ${key}`)
stringValue(message['senderName'], `${label} senderName`)
diff --git a/packages/session/session-format-v0-to-v1/src/relationships.ts b/packages/session/session-format-v0-to-v1/src/relationships.ts
index e220924840..2ab5755c0d 100644
--- a/packages/session/session-format-v0-to-v1/src/relationships.ts
+++ b/packages/session/session-format-v0-to-v1/src/relationships.ts
@@ -48,7 +48,8 @@ export function assertReleasedArtifactRelationships(artifact: SessionFormatArtif
const commandRuns = new Set()
for (const event of artifact.events) {
- if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined) continue
+ if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined
+ && event.type !== 'assistant/attempt') continue
const data = releasedV0Record(event.data, `${event.type} ${event.seq} data`)
if (SURFACE_TYPES.has(event.type)) surface = applySurface(surface, event)
if ((event.type === 'turn/start' || event.type === 'turn/end')
@@ -94,6 +95,7 @@ export function assertReleasedArtifactRelationships(artifact: SessionFormatArtif
nextStep += 1
break
case 'assistant/chunk':
+ case 'assistant/attempt':
requireOpenStep(event, data, openTurn, openStep)
break
case 'assistant/message': {
diff --git a/packages/session/session-format-v0-to-v1/src/validation.ts b/packages/session/session-format-v0-to-v1/src/validation.ts
index 123653e6b1..b6500fb4ad 100644
--- a/packages/session/session-format-v0-to-v1/src/validation.ts
+++ b/packages/session/session-format-v0-to-v1/src/validation.ts
@@ -164,12 +164,27 @@ function assertArtifactCoordinates(
if (record['ignorable'] !== undefined && record['ignorable'] !== true) {
throw new SessionFormatError(`Session event ${index} ignorable must be true when present`)
}
- if (frozenEnvelope && surface) assertSurfaceMetadata(record, index, type)
+ if (frozenEnvelope && surface) assertReleasedSurfaceMetadata(record, index, type, 'allow-empty-assistant')
}
}
-function assertSurfaceMetadata(record: Record, seq: number, type: string): void {
+/**
+ * Validate shared-layout surface references for one released generation.
+ * @param record - exact event envelope.
+ * @param seq - event position used for earlier-reference checks.
+ * @param type - surface event type used in diagnostics.
+ * @param assistantSources - whether this generation admits empty Assistant chunk provenance.
+ */
+export function assertReleasedSurfaceMetadata(
+ record: Record,
+ seq: number,
+ type: string,
+ assistantSources: 'allow-empty-assistant' | 'forbid-assistant',
+): void {
const sources = record['sourceEventSeqs']
+ if (type === 'assistant/message' && sources !== undefined && assistantSources === 'forbid-assistant') {
+ throw new SessionFormatError(`assistant/message ${seq} retains obsolete chunk provenance`)
+ }
if (sources !== undefined) {
if (!Array.isArray(sources)) throw new SessionFormatError(`${type} ${seq} sourceEventSeqs must be an array`)
const seen = new Set()
@@ -180,7 +195,8 @@ function assertSurfaceMetadata(record: Record, s
}
seen.add(current)
}
- if (sources.length === 0 && type !== 'assistant/message') {
+ if (sources.length === 0
+ && (type !== 'assistant/message' || assistantSources === 'forbid-assistant')) {
throw new SessionFormatError(`${type} ${seq} sourceEventSeqs must be non-empty`)
}
}
diff --git a/packages/session/session-format-v0-to-v1/tests/validation.spec.ts b/packages/session/session-format-v0-to-v1/tests/validation.spec.ts
index be90d4f21e..81145e57d9 100644
--- a/packages/session/session-format-v0-to-v1/tests/validation.spec.ts
+++ b/packages/session/session-format-v0-to-v1/tests/validation.spec.ts
@@ -211,7 +211,10 @@ describe('released event and payload inventory', () => {
it('has an executable valid fixture for every frozen released-v0 event type', () => {
expect(Object.keys(validPayloads).sort()).toEqual([...RELEASED_V0_EVENT_TYPES].sort())
expect(RELEASED_V0_EVENT_TYPES).toHaveLength(51)
- expect(RELEASED_V0_EVENT_TYPES.every(type => KNOWN_SESSION_EVENT_TYPES.has(type))).toBe(true)
+ expect(RELEASED_V0_EVENT_TYPES
+ .filter(type => type !== 'assistant/chunk')
+ .every(type => KNOWN_SESSION_EVENT_TYPES.has(type))).toBe(true)
+ expect(KNOWN_SESSION_EVENT_TYPES.has('assistant/chunk')).toBe(false)
for (const [type, data] of Object.entries(validPayloads)) {
expect(() => { assertPayload(type, data) }, type).not.toThrow()
}
diff --git a/packages/session/session-format-v1-to-v2/README.i18n.yaml b/packages/session/session-format-v1-to-v2/README.i18n.yaml
new file mode 100644
index 0000000000..474383348a
--- /dev/null
+++ b/packages/session/session-format-v1-to-v2/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/session/session-format-v1-to-v2/README.md
+README.md: 7ebfddee768dc2bef0f4df69c85fc3a0c7e64bb1
+README.zh.md: ceb44c530fac753b8f4fcef79817a8a08dfddb40
diff --git a/packages/session/session-format-v1-to-v2/README.md b/packages/session/session-format-v1-to-v2/README.md
new file mode 100644
index 0000000000..7ebfddee76
--- /dev/null
+++ b/packages/session/session-format-v1-to-v2/README.md
@@ -0,0 +1,120 @@
+---
+description: "Frozen released-v1 Session reader and cardinality-changing migration that embeds Assistant streams in released v2 events."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-session-format-v1-to-v2
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-session-format-v1-to-v2` converts a complete released-v1 Session into the released-v2 event model. It consumes top-level `assistant/chunk` events, embeds their exact timed stream in the matching `assistant/message`, and records an `assistant/attempt` when a failed, retried, cancelled, or crash-tail attempt produced no surface message. The edge densely remaps surviving events and every declared same-Session sequence reference, while the v2 codec stores one event per row and derives the inherited cut from a tagged `session/end-seed` marker.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+
+## Use this package
+
+### When to use it
+
+Persistence obtains this edge through `dsh-session-format-catalog`; feature compositions do not mount it. Import it directly only when assembling or testing the static released-format catalog or inspecting the exact v1-to-v2 transformation. No runtime invariant companion is published because every codec and migration call validates its complete source or target artifact and retains no runtime state.
+
+### Entry point
+
+```text
+const decodedV1 = releasedV1SessionFormatCodec.decodeArtifact(header, rows)
+const migratedV2 = sessionFormatV1ToV2.migrate(decodedV1)
+```
+
+`releasedV1SessionFormatCodec` reads the frozen v1 physical language. `sessionFormatV1ToV2` validates that complete source, performs the cardinality-changing transformation, remaps declared references, and validates the exact v2 result. `releasedV2SessionFormatCodec` then encodes or decodes the current physical representation.
+
+A successful v1 `assistant/message` must cite its complete ordered attempt. The migration removes the cited top-level chunks and obsolete message provenance, compacts the chunks without joining token boundaries, and stores the stream on that message. An unclaimed attempt becomes one log-only `assistant/attempt` at its final chunk position. Unrelated interleaved events keep their relative order.
+
+The migration refuses a reference to a consumed chunk instead of redirecting it to a different semantic event. It remaps declared event provenance, surface replacements, command source events, compaction ranges and lists, and title message lists. A seeded source also refuses an inherited cut that splits an Assistant attempt; the target marks the exact cut with `session/end-seed { inherited: true }`.
+
+The v2 physical header requires `isSeeded` and does not store a numeric cut. The codec derives the cut from the last inherited end-seed marker, writes one event per row, and range-encodes only `sourceEventSeqs`. Strict v2 validation rejects unknown event types even when their envelope is ignorable, unexpected members, malformed compact streams, and disagreement between a non-empty stream and its assembled message, usage, or replay state.
+
+### Measure current-read acceptance
+
+```text
+node --expose-gc --import tsx/esm packages/session/session-format-v1-to-v2/benchmarks/acceptance.ts
+```
+
+The manual acceptance runs three repetitions with 100 warmup pairs and 600 alternating measured pairs per case. It compares the current v2 catalog-dispatch read against a direct-current read of the same backend, id, file, and decoder, and requires every pooled median and p95 regression to stay within 5%. Add `--smoke` only for a short correctness and reporting pass; smoke timing is non-gating and is not an acceptance result.
+
+-----
+
+
+## Understand the implementation
+
+
+