fix(session): address v2 migration review

This commit is contained in:
Tianyi Cui committed 2026-09-03 04:45:41 +08:00
1 parent a88ef734cc
commit 67c8a58854
74 files changed
+921 -395

No files matched your search

@@ -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-session-log-version-mechanism.md
2026-08-10-session-log-version-mechanism.md: 19650ca6a02139d6ad138c6914185f210386ce49
2026-08-10-session-log-version-mechanism.zh.md: a54a1f30d2d30e55b7c03d529e1ca337346ce2d2
2026-08-10-session-log-version-mechanism.md: 98eb220e49457d3a2783edefce13d053c9b94025
2026-08-10-session-log-version-mechanism.zh.md: 8853e6ed9a1a1ed43193cfe0949fffdbb9b4a26f
@@ -20,7 +20,7 @@ Session logs must be upgradable after release, and the runtime that ships first
## Consequences
What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. V1 adds the static adjacent catalog, the identity v0-to-v1 edge, header-only descriptors, exact-generation JSONL publication, and current-only restoration described in [Released Session formats](2026-08-31-released-session-format-migrations.md). First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; equal-version retention lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md), and the stricter historical rule lives in the [alpha migration refusal decision](2026-08-31-alpha-historical-unknown-event-refusal.md). The unknown-type guard remains read-side because append-time vocabulary refusal would stall a live session's durability. JSONL classifies foreign versions from the minimal raw header before current-header or event parsing, so a structurally different future format reports the upgrade direction instead of "corrupt".
What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. V1 adds the static adjacent catalog, the identity v0-to-v1 edge, header-only descriptors, exact-generation JSONL publication, and current-only restoration described in [Released Session formats](2026-08-31-released-session-format-migrations.md). V2 keeps the physical codec neutral to ordinary event vocabulary and payload additions: the adjacent edge freezes its released source and target inventories, while equal-version restoration applies the installed known-event set and current payload semantics. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; equal-version retention lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md), and the stricter historical rule lives in the [alpha migration refusal decision](2026-08-31-alpha-historical-unknown-event-refusal.md). The unknown-type guard remains read-side because append-time vocabulary refusal would stall a live session's durability. JSONL classifies foreign versions from the minimal raw header before current-header or event parsing, so a structurally different future format reports the upgrade direction instead of "corrupt".
## Alternatives considered
@@ -20,7 +20,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
## 影响
v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。V1 添加静态相邻 catalog、恒等 v0-to-v1 迁移边、仅 header descriptor、精确代际 JSONL 发布与[已发布 Session 格式](2026-08-31-released-session-format-migrations.zh.md)定义的当前专用恢复。第一方 writer 不通过 `Session.append` 设置 `ignorable`,而一个仓库外插件仍依赖该字段;同版本保留由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义,更严格的历史规则由 [alpha 迁移拒绝决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义。未知类型守卫仍只在读取侧生效,因为 append 时的词汇拒绝会中断活跃 Session 的持久化。JSONL 会在当前 header 或事件解析前从最小原始 header 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。
v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。V1 添加静态相邻 catalog、恒等 v0-to-v1 迁移边、仅 header descriptor、精确代际 JSONL 发布与[已发布 Session 格式](2026-08-31-released-session-format-migrations.zh.md)定义的当前专用恢复。V2 让物理 codec 对普通事件词汇与 payload 新增项保持中立:相邻迁移边冻结 released source 与 target 清单,同版本恢复则应用已安装的 known-event set 与当前 payload 语义。第一方 writer 不通过 `Session.append` 设置 `ignorable`,而一个仓库外插件仍依赖该字段;同版本保留由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义,更严格的历史规则由 [alpha 迁移拒绝决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义。未知类型守卫仍只在读取侧生效,因为 append 时的词汇拒绝会中断活跃 Session 的持久化。JSONL 会在当前 header 或事件解析前从最小原始 header 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。
## 曾考虑的替代方案
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
2026-09-01-v2-embedded-assistant-streams.md: 80e06b34fbc675c6b6a680c1615bdebe8019bf1a
2026-09-01-v2-embedded-assistant-streams.zh.md: 248444c62ca3004c1fbe37897b2da9377211338e
2026-09-01-v2-embedded-assistant-streams.md: e50af4848523c92daaac1bd8b1120c26cdf62bdc
2026-09-01-v2-embedded-assistant-streams.zh.md: 34924a43150c34571a6c5f2e7837d1b662d2ec46
@@ -27,7 +27,7 @@ The current v2 validator requires the embedded stream to reproduce a non-empty `
`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 process-local frames and adds the last durable sequence observed at each start. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages only a later matching settlement until the committed end, replaces that attempt's transient rows with the settlement, and reopens follow on a revision gap. A reconnect baseline carries the active attempt's durable start cursor and compact prefix. Paged history, replay, telemetry, token accounting, and cold UI assembly read the durable embedded stream rather than the live frames.
The Web follow adapter opts into these process-local frames and adds the last durable sequence observed at each start. It presents chunks as Client-only `assistant/live-chunk` updates between durable cursors, stages only a later matching settlement until the committed end, and reopens follow on a revision gap. A committed end publishes a named settlement delta that removes the attempt's transient matches, adds the durable entry, and replays only affected Conversation Contexts; an abandoned end publishes the same delta without an entry. A reconnect baseline carries the active attempt's durable start cursor and 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
@@ -35,7 +35,11 @@ The adjacent migration validates the complete frozen v1 artifact, groups chunks
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.
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, range-encodes only `sourceEventSeqs`, and validates physical envelopes without freezing ordinary event vocabulary or payload additions. The v1-to-v2 target validator separately freezes the released-v2 inventory, while current restoration uses the installed Session vocabulary. Frozen v0 and v1 codecs retain packed-row decoding for their immutable historical generations.
A fresh subagent child's constructor seed is exactly the inherited parent prefix. `Session` appends the tagged cut marker, then subagent setup appends the child-owned descriptor and delegated policies. The former descriptor-seed helper is removed, so a descriptor is never counted as inherited and cold resume replays the persisted child-owned setup. Historical snapshot fixtures that placed an untagged marker after the descriptor are corrected at their source; current comparison keeps marker count and sequence references visible.
The `dsh_session_log` request extension increments its outer schema to version 2 because its Session header projection replaces `seedLength` with required `isSeeded`. Its `sessionFormatVersion` member continues to identify the embedded logical Session generation.
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.
@@ -43,7 +47,7 @@ Generation selection and publication follow the [released Session migration deci
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 3.150%. `--smoke` reports a non-gating diagnostic sample.
The manual performance acceptance measures static catalog-routing overhead against direct released-v2 restoration of the same already parsed physical rows across three runs, 100 warmup pairs, and 600 measured pairs. It does not compare v1 with v2 or time backend I/O. Every pooled median and p95 regression must remain within 5%; the accepted run's worst p95 regression was 3.150%. `--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.
@@ -27,7 +27,7 @@ Session format v2 没有顶层 `assistant/chunk` 事件。每个模型 attempt
`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 命名其类型和序号前追加完整的 `assistant/message` 或 `assistant/attempt`。abandoned end 没有 settlement。
Web follow adapter 显式选择接收这些进程本地 frame,并为每个 start 补充当时观察到的最后一个持久序号。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,只暂存 start 之后匹配的 settlement,在 committed end 到达时用 settlement 替换该 attempt 的瞬态 row,并在 revision 缺口时重新打开 follow。重连 baseline 携带活跃 attempt 的持久起始 cursor 与紧凑前缀。分页历史、replay、遥测、token 记账与冷 UI 组装读取持久嵌入式 stream,而不是 live frame。
Web follow adapter 显式选择接收这些进程本地 frame,并为每个 start 补充当时观察到的最后一个持久序号。它把 chunk 呈现为持久 cursor 之间的 Client-only `assistant/live-chunk` update,只暂存 start 之后匹配的 settlement,并在 revision 缺口时重新打开 follow。committed end 会发布具名 settlement delta,删除该 attempt 的 transient match、加入持久 entry,并只重放受影响的 Conversation Context;abandoned end 会发布不含 entry 的同类 delta。重连 baseline 携带活跃 attempt 的持久起始 cursor 与紧凑前缀。分页历史、replay、遥测、token 记账与冷 UI 组装读取持久嵌入式 stream,而不是 live frame。
### 已发布 v1 到 v2 迁移
@@ -35,7 +35,11 @@ Web follow adapter 显式选择接收这些进程本地 frame,并为每个 sta
该迁移边会重映射有限的已声明引用清单:信封 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。
v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产物用 `session/end-seed { inherited: true }` 标记其精确切点;解码从最后一个 tagged marker 推导切点。v2 编解码器为每个持久事件写一条物理行,只对 `sourceEventSeqs` 做范围编码,并在不冻结普通事件词汇或 payload 新增项的前提下校验物理 envelope。v1-to-v2 target validator 会另行冻结 released-v2 清单,current restoration 则使用 installed Session 词汇。冻结的 v0 与 v1 编解码器继续为不可变历史 generation 解码 packed row。
新建 subagent 子项的 constructor seed 与继承的父项前缀完全相同。`Session` 会追加 tagged cut marker,随后 subagent setup 再追加子项持有的 descriptor 与 delegated policy。原 descriptor-seed helper 会被删除,因此 descriptor 绝不会计入继承内容,cold resume 则重放已经持久化的子项 setup。曾把 untagged marker 放在 descriptor 后面的历史 snapshot fixture 会在源处修正;当前比较仍会暴露 marker 数量与序号引用。
`dsh_session_log` request extension 会把外层 schema 升至版本 2,因为它的 Session header 投影会用必需的 `isSeeded` 取代 `seedLength`。其中的 `sessionFormatVersion` 成员仍用于标识嵌入的逻辑 Session generation。
Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-released-session-format-migrations.zh.md):源路径、字节与 inode 保持不变,只发布最终具名版本 successor;保留 predecessor 不提供 fallback 或 downgrade 支持。
@@ -43,7 +47,7 @@ Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-rele
紧凑 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 为 3.150%。`--smoke` 报告不参与 gate 的诊断 sample。
手工 performance acceptance 会在三轮、100 组 warmup pair 与 600 组 measured pair 下,针对同一批已经解析的物理 row,把静态 catalog routing 与直接 released-v2 restoration 比较;它不比较 v1 与 v2,也不计入 backend I/O。每个 pooled median 与 p95 regression 都必须保持在 5% 以内;已接受运行的最差 p95 regression 为 3.150%。`--smoke` 报告不参与 gate 的诊断 sample。
Agent-loop 测试固定先持久后 end 的顺序、中断的可见前缀、失败与重试 attempt、abandonment、usage 与 replay metadata。Session Controller 与 Conversation 测试固定实时瞬态显示、重连 baseline、committed settlement 发布、历史回放以及 Chat 与 Trajectory 一致性;TypeScript 与 Python SDK snapshot 固定外部事件表示。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 2bd56cf807ce7433ead009e5f6df73c6151a6664
config-catalog.zh.md: ef61011aadd14c22c3a8a0fb3d45b029194a166b
config-catalog.md: 8ff7f5818dd43842b9e934901863c3e9b54656ef
config-catalog.zh.md: 11a8ef58ed16a6bd7616284c292491fb135aa1e1
+1 -1
View File
@@ -1353,7 +1353,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:1388`](../packages/test-support/llm-replay/src/index.ts)
Source: [`packages/test-support/llm-replay/src/index.ts:1304`](../packages/test-support/llm-replay/src/index.ts)
<a id="deepseek-aidsh-llm-retry"></a>
+1 -1
View File
@@ -1355,7 +1355,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:1388`](../packages/test-support/llm-replay/src/index.ts)
来源:[`packages/test-support/llm-replay/src/index.ts:1304`](../packages/test-support/llm-replay/src/index.ts)
<a id="deepseek-aidsh-llm-retry"></a>
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md
deepseek-llm-api-wire-extensions.md: 678661ad1794a76e9e872e041415bb2f0f4db682
deepseek-llm-api-wire-extensions.zh.md: 83b99d957a20fe3068f862c31faf499f6767acae
deepseek-llm-api-wire-extensions.md: 5b1c4de68949d56f99cbfa70ba4af7ca0b71715d
deepseek-llm-api-wire-extensions.zh.md: a78baace34dcb3e24667295cf06e111ce355aa72
+3 -3
View File
@@ -78,7 +78,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
```json
{
"dsh_session_log": {
"version": 1,
"version": 2,
"sessionFormatVersion": 2,
"session": {
"version": 2,
@@ -104,7 +104,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
| Member | Type | Meaning |
|---|---|---|
| `version` | `1` | Schema version for `dsh_session_log` |
| `version` | `2` | 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 |
| `afterSeq` | integer | Greatest sequence recorded as accepted before this request, or `-1` |
@@ -115,7 +115,7 @@ 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, 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.
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. Changing the Session header projection requires an extension-schema bump even when the embedded logical format also changes.
| Member | Presence | Meaning |
|---|---|---|
+3 -3
View File
@@ -78,7 +78,7 @@
```json
{
"dsh_session_log": {
"version": 1,
"version": 2,
"sessionFormatVersion": 2,
"session": {
"version": 2,
@@ -104,7 +104,7 @@
| 成员 | 类型 | 含义 |
|---|---|---|
| `version` | `1` | `dsh_session_log` 的 schema 版本 |
| `version` | `2` | `dsh_session_log` 的 schema 版本 |
| `sessionFormatVersion` | 非负整数 | 该后缀所表示的 Session 格式 generation |
| `session` | 对象 | 当前 Session header 的不可变协议投影 |
| `afterSeq` | 整数 | 本次请求前记录为已接受的最大序号,或 `-1` |
@@ -115,7 +115,7 @@
### Session 协议 header
`session` 成员投影 `Session.header`,既不是完整的运行时 Session,也不是 header 对象本身。它复制当前 header 事实,包括必需的 `isSeeded` 谱系位;精确的 `Session.inheritedEventCount` 不属于该请求字段。外层 `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 格式。即使嵌入的逻辑格式同时变化,只要 Session header 投影变化,扩展 schema 也必须升版。
| 成员 | 出现条件 | 含义 |
|---|---|---|
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/core.md
core.md: 8b271824dddc9d71a3a160ba1bd034c6cff52ab8
core.zh.md: 0564d2435a35c7c1f6c8c24851dab497ba967586
core.md: 8050447620bf882da898dc6d2cec9559716a3d5e
core.zh.md: 236a17e31c4f7921126b5cf222f63de14f3bd725
+10 -6
View File
@@ -171,9 +171,9 @@ 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'
@@ -181,10 +181,14 @@ type AssistantStreamFrame =
readonly revision: number
/** Number of chunk frames emitted by this attempt. */
readonly index: number
/** `committed` follows its durable message; `aborted` has no such requirement. */
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' }
}
```
+10 -6
View File
@@ -175,9 +175,9 @@ 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'
@@ -185,10 +185,14 @@ type AssistantStreamFrame =
readonly revision: number
/** Number of chunk frames emitted by this attempt. */
readonly index: number
/** `committed` follows its durable message; `aborted` has no such requirement. */
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' }
}
```
@@ -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: 0e023521615b00208c90f20a643ec2b87463d42d
README.zh.md: ddbddff545eec58fa23805c9564cb5f87467b5a6
README.md: ae900465f46186e5fb9fcdd5a0dceb5a419b3cfe
README.zh.md: 6bb3ad7804a43ac2869f684e8d120d3ce8de2303
+1 -1
View File
@@ -27,7 +27,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. 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 frames: each opening carries the active attempt's `startedTime`, `startedAfterSeq`, `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. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publish it while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes no durable settlement. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. 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 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`, `append`, and `settle-assistant` 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`, `startedAfterSeq`, `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. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. 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.
+1 -1
View File
@@ -27,7 +27,7 @@ kind: "package-reference"
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、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 frame:每个 opening 携带活跃 attempt 的 `startedTime`、`startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 到达后再发布,而同一步骤中更早的 retry 保持可见。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 不发布持久 settlement。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、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` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedTime`、`startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 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 重建会话。
@@ -26,6 +26,11 @@ export type SessionEventLikeEntry =
/** Scalar live entry accepted by append-only Client paths. */
export type SessionLiveEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'event' }>
/** Durable Assistant event that atomically supersedes one attempt's transient rows. */
export interface SessionAssistantSettlementEntry {
readonly type: 'event'
readonly event: SessionEvent<'assistant/message'> | SessionEvent<'assistant/attempt'>
}
/** Client-only Assistant frame admitted outside durable cursor algebra. */
export type SessionTransientEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'transient' }>
@@ -94,6 +99,11 @@ export type SessionEventChange =
| { readonly kind: 'replace'; readonly entries: readonly SessionEventLikeEntry[] }
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventLikeEntry[] }
| { readonly kind: 'append'; readonly entries: readonly SessionEventLikeEntry[] }
| {
readonly kind: 'settle-assistant'
readonly attemptId: LlmAttemptId
readonly entry?: SessionAssistantSettlementEntry
}
/** Current contiguous event window and its latest synchronous delta. */
export interface SessionEventWindow {
@@ -168,13 +178,21 @@ export class MutableSessionEventSource implements SessionEventSource {
* @param attemptId - process-local attempt whose live rows are now redundant.
* @param entry - durable settlement committed for that attempt.
*/
settleAssistant(attemptId: string, entry: SessionLiveEventEntry): void {
settleAssistant(attemptId: LlmAttemptId, entry?: SessionAssistantSettlementEntry): void {
const entries = materialize(this.window).filter(candidate => (
candidate.type !== 'transient' || String(candidate.event.data.attemptId) !== attemptId
candidate.type !== 'transient' || candidate.event.data.attemptId !== attemptId
))
entries.push(entry)
if (entry !== undefined) {
const index = entries.findIndex(candidate => candidate.event.seq > entry.event.seq)
if (index < 0) entries.push(entry)
else entries.splice(index, 0, entry)
}
this.window = leaf(entries)
this.publish(this.snapshot.hasMore, { kind: 'replace', entries })
this.publish(this.snapshot.hasMore, {
kind: 'settle-assistant',
attemptId,
...(entry === undefined ? {} : { entry }),
})
}
private publish(
@@ -49,12 +49,15 @@ export type {
export type { ISessions } from './contract/sessions.ts'
export { MutableSessionEventSource } from './contract/events.ts'
export type {
AssistantLiveChunkEvent,
SessionAssistantSettlementEntry,
SessionEventChange,
SessionEventLike,
SessionEventLikeEntry,
SessionEventSource,
SessionEventWindow,
SessionLiveEventEntry,
SessionTransientEventEntry,
} from './contract/events.ts'
export type {
OpenState,
@@ -6,14 +6,16 @@ import type {
} from '../../types.ts'
import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { AssistantStreamRecord } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
import type {
SessionAssistantSettlementEntry,
SessionEventLikeEntry,
SessionLiveEventEntry,
SessionTransientEventEntry,
} from '../contract/events.ts'
interface ActiveAttempt {
readonly attemptId: string
readonly attemptId: LlmAttemptId
readonly startedAfterSeq: number
readonly turn: number
readonly step: number
@@ -25,9 +27,10 @@ export type ClientAssistantStreamResult =
| { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
| {
readonly type: 'settlement'
readonly attemptId: string
readonly entry: SessionLiveEventEntry
readonly attemptId: LlmAttemptId
readonly entry: SessionAssistantSettlementEntry
}
| { readonly type: 'abandonment'; readonly attemptId: LlmAttemptId }
| { readonly type: 'transient'; readonly entry: SessionTransientEventEntry }
| { readonly type: 'rebaseline' }
| undefined
@@ -35,7 +38,7 @@ export type ClientAssistantStreamResult =
/** Keeps transient Assistant presentation behind one settlement-aware interface. */
export class ClientAssistantStream {
private activeAttempt: ActiveAttempt | undefined
private readonly pending = new Map<number, SessionLiveEventEntry>()
private readonly pending = new Map<number, SessionAssistantSettlementEntry>()
private publishedSeqs = new Set<number>()
private durableCursor = -1
private transientInGap = 0
@@ -56,7 +59,7 @@ export class ClientAssistantStream {
const opening = baseline?.activeAttempt
if (opening !== undefined) {
this.activeAttempt = {
attemptId: String(opening.attemptId),
attemptId: opening.attemptId,
startedAfterSeq: opening.startedAfterSeq,
turn: opening.turn,
step: opening.step,
@@ -100,9 +103,10 @@ export class ClientAssistantStream {
const event = entry.event
this.durableCursor = Math.max(this.durableCursor, event.seq)
this.transientInGap = 0
if (this.attemptForSettlement(event) !== undefined) {
const settlement = assistantSettlementEntry(entry)
if (settlement !== undefined && this.attemptForSettlement(settlement.event) !== undefined) {
if (this.pending.has(event.seq)) return { type: 'rebaseline' }
this.pending.set(event.seq, entry)
this.pending.set(event.seq, settlement)
return undefined
}
return this.publish(entry)
@@ -119,7 +123,7 @@ export class ClientAssistantStream {
if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' }
this.pending.clear()
this.activeAttempt = {
attemptId: String(frame.attemptId),
attemptId: frame.attemptId,
startedAfterSeq: frame.startedAfterSeq,
turn: frame.turn,
step: frame.step,
@@ -131,7 +135,7 @@ export class ClientAssistantStream {
// A controller mounted after the Host saw this attempt has no start
// frame to reconstruct. Its durable settlement publishes directly;
// ignore the transient suffix until the next known start.
if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) return undefined
if (attempt === undefined || attempt.attemptId !== frame.attemptId) return undefined
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
attempt.nextIndex += 1
this.transientInGap += 1
@@ -155,13 +159,15 @@ export class ClientAssistantStream {
}
case 'end': {
const attempt = this.activeAttempt
if (attempt === undefined || attempt.attemptId !== String(frame.attemptId)) {
if (attempt === undefined || attempt.attemptId !== frame.attemptId) {
return undefined
}
this.activeAttempt = undefined
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
if (frame.outcome.kind === 'abandoned') {
return this.pending.size === 0 ? undefined : { type: 'rebaseline' }
return this.pending.size === 0
? { type: 'abandonment', attemptId: attempt.attemptId }
: { type: 'rebaseline' }
}
if (this.publishedSeqs.has(frame.outcome.seq)) return undefined
const entry = this.pending.get(frame.outcome.seq)
@@ -177,11 +183,10 @@ export class ClientAssistantStream {
}
private attemptForSettlement(
event: SessionLiveEventEntry['event'],
event: SessionAssistantSettlementEntry['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.seq <= attempt.startedAfterSeq
|| attempt.turn !== event.data.turn
@@ -194,3 +199,11 @@ export class ClientAssistantStream {
return { type: 'publish', entry }
}
}
function assistantSettlementEntry(
entry: SessionLiveEventEntry,
): SessionAssistantSettlementEntry | undefined {
return entry.event.type === 'assistant/message' || entry.event.type === 'assistant/attempt'
? entry as SessionAssistantSettlementEntry
: undefined
}
@@ -674,6 +674,11 @@ export class Session implements SessionFace {
this.notifier.markDirty()
return
}
if (result?.type === 'abandonment') {
this.eventSource.settleAssistant(result.attemptId)
this.notifier.markDirty()
return
}
if (result?.type === 'publish' && this.appendLive(result.entry)) {
this.notifier.markDirty()
} else if (result?.type === 'transient') {
@@ -112,14 +112,12 @@ describe('ClientAssistantStream', () => {
const visible = stream.replace([durable], baseline(1))
expect(visible[0]).toBe(durable)
expect(visible.slice(1)).toEqual([expect.objectContaining({
type: 'transient',
event: expect.objectContaining({
type: 'assistant/live-chunk',
seq: 4.5,
time: 20,
}),
})])
expect(visible).toHaveLength(2)
const reconstructed = visible[1]
if (reconstructed?.type !== 'transient') throw new Error('expected reconstructed transient chunk')
expect(reconstructed.event.type).toBe('assistant/live-chunk')
expect(reconstructed.event.seq).toBe(4.5)
expect(reconstructed.event.time).toBe(20)
expect(stream.replace([], baseline(3))).toHaveLength(2)
expect(stream.replace([])).toEqual([])
@@ -183,7 +181,10 @@ describe('ClientAssistantStream', () => {
it('settles abandonment only when no durable settlement remains pending', () => {
const empty = opened()
expect(empty.acceptFrame(end(0, { kind: 'abandoned' }))).toBeUndefined()
expect(empty.acceptFrame(end(0, { kind: 'abandoned' }))).toEqual({
type: 'abandonment',
attemptId: String(ATTEMPT),
})
const pending = opened()
expect(pending.acceptDurable(attemptEvent(2))).toBeUndefined()
@@ -2,8 +2,10 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import type { PromptContentPart as AttachmentPromptContentPart } from '@deepseek-ai/dsh-attachment/types'
import { SessionSeq, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import {
MutableSessionEventSource, type SessionLiveEventEntry,
MutableSessionEventSource, type SessionAssistantSettlementEntry,
type SessionLiveEventEntry, type SessionTransientEventEntry,
} from '../src/client/contract/events.ts'
import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
import type { ISession } from '../src/client/contract/session.ts'
import type { ProjectionsBaseline } from '../src/client/sessions/projection-store.ts'
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
@@ -24,6 +26,35 @@ function entry(seq: SessionSeq): SessionLiveEventEntry {
}
}
function transient(attemptId: string, seq = 1.5): SessionTransientEventEntry {
return {
type: 'transient',
event: {
type: 'assistant/live-chunk',
seq,
time: 1,
data: {
attemptId: LlmAttemptId(attemptId),
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'live' },
},
},
}
}
function assistantSettlement(seq: SessionSeq): SessionAssistantSettlementEntry {
return {
type: 'event',
event: {
type: 'assistant/attempt',
seq,
time: seq,
data: { turn: 1, step: 1, stream: [] },
},
}
}
describe('Client Session contracts', () => {
it('requires branded Session positions at internal event fixture boundaries', () => {
expectTypeOf(entry).parameter(0).toEqualTypeOf<SessionSeq>()
@@ -105,4 +136,34 @@ describe('Client Session contracts', () => {
expect(iterate).toHaveBeenCalledOnce()
})
it('publishes one exact Assistant settlement delta after retiring its transient rows', () => {
const feed = new MutableSessionEventSource()
const opening = entry(SessionSeq(1))
const live = transient('attempt-one')
const settlement = assistantSettlement(SessionSeq(2))
const later = entry(SessionSeq(3))
feed.replace([opening, live, later], false)
feed.settleAssistant(LlmAttemptId('attempt-one'), settlement)
expect(feed.getSnapshot()).toEqual({
entries: [opening, settlement, later],
hasMore: false,
revision: 2,
change: {
kind: 'settle-assistant',
attemptId: 'attempt-one',
entry: settlement,
},
})
feed.append(transient('attempt-two', 3.5))
feed.settleAssistant(LlmAttemptId('attempt-two'))
expect(feed.getSnapshot().entries).toEqual([opening, settlement, later])
expect(feed.getSnapshot().change).toEqual({
kind: 'settle-assistant',
attemptId: 'attempt-two',
})
})
})
@@ -335,7 +335,7 @@ describe('scope tree', () => {
.toEqual(['assistant/message', 'assistant/message'])
})
expect(binding.eventSource.getSnapshot().change).toEqual({
kind: 'replace', entries: [priorMessage, currentMessage],
kind: 'settle-assistant', attemptId: String(attemptId), entry: currentMessage,
})
})
+1
View File
@@ -48,6 +48,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
@@ -26,6 +26,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
import type {
SessionEvent,
SessionId,
SessionSeqCursor,
} from '@deepseek-ai/dsh-session/types'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -166,13 +167,14 @@ interface FixtureAssistantStreamBaseline {
}
}
type FixtureAssistantStreamFrame =
/** Assistant frame emitted by the standalone fixture; tests pin it to the controller wire type. */
export type FixtureAssistantStreamFrame =
| {
readonly type: 'start'
readonly attemptId: ReturnType<typeof LlmAttemptId>
readonly revision: number
readonly startedTime: number
readonly startedAfterSeq: number
readonly startedAfterSeq: SessionSeqCursor
readonly turn: number
readonly step: number
}
@@ -182,14 +184,19 @@ type FixtureAssistantStreamFrame =
readonly revision: number
readonly index: number
readonly time: number
readonly chunk: StreamChunk
readonly chunk: JsonValue
}
| {
readonly type: 'end'
readonly attemptId: ReturnType<typeof LlmAttemptId>
readonly revision: number
readonly index: number
readonly outcome:
| { readonly kind: 'committed'; readonly eventType: 'assistant/message' | 'assistant/attempt'; readonly seq: number }
| {
readonly kind: 'committed'
readonly eventType: 'assistant/message' | 'assistant/attempt'
readonly seq: number
}
| { readonly kind: 'abandoned' }
}
@@ -2081,7 +2088,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
const beginAssistant = (sessionId: SessionId, turn: number, step: number): FixtureAttemptState => {
const attemptId = LlmAttemptId(`${sessionId}:fixture:${String(nextAssistantRevision(sessionId))}`)
const startedTime = Date.now()
const startedAfterSeq = logOf(sessionId).length - 1
const lastSeq = logOf(sessionId).length - 1
const startedAfterSeq = lastSeq < 0 ? -1 : SessionSeq(lastSeq)
const attempt = {
attemptId, startedTime, startedAfterSeq, turn, step,
stream: new AssistantStreamAccumulator(), index: 0,
@@ -2099,7 +2107,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
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,
index: attempt.index++, time: timed.time, chunk: timed.chunk as unknown as JsonValue,
})
}
const commitAssistant = (sessionId: SessionId, event: SessionEvent): void => {
@@ -2108,6 +2116,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
activeAttempts.delete(sessionId)
emitAssistant(sessionId, {
type: 'end', attemptId: attempt.attemptId, revision: nextAssistantRevision(sessionId),
index: attempt.index,
outcome: {
kind: 'committed',
eventType: event.type === 'assistant/message' ? 'assistant/message' : 'assistant/attempt',
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import type {
RpcRequest,
RpcResponse,
@@ -11,14 +11,16 @@ import { RpcId } from '../src/client/api.ts'
import {
createFixtureConnectionRpc,
createFixtureFaces,
type FixtureAssistantStreamFrame,
type FixtureOptions,
} from '../src/client/fixture.ts'
import type {
ClientConnectionRpc, ConnectionRpcResult,
} from '../src/rpc.ts'
import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
import type {
ModelCatalog, ModelSelection, SessionAssistantStreamFrame,
} from '@deepseek-ai/dsh-api-session-controller/types'
const sid = (id: string): SessionId => id as SessionId
type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' }
@@ -52,32 +54,13 @@ function historyEvents(records: readonly FixtureHistoryRecord[]): SessionEvent[]
return records.map(record => record.event)
}
type FixtureAssistantStreamFrame =
| {
readonly type: 'start'
readonly attemptId: string
readonly revision: number
readonly startedTime: number
readonly startedAfterSeq: 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' }
}
function isReasoningDeltaChunk(
value: unknown,
): value is Extract<StreamChunk, { readonly type: 'reasoning-delta' }> {
if (typeof value !== 'object' || value === null) return false
const record = value as { readonly type?: unknown; readonly text?: unknown }
return record.type === 'reasoning-delta' && typeof record.text === 'string'
}
type FixtureFollowFrame =
| {
@@ -616,6 +599,10 @@ async function readWorkspaceBaseline(
}
describe('createFixtureApi', () => {
it('keeps its Assistant frame vocabulary identical to the controller wire', () => {
expectTypeOf<FixtureAssistantStreamFrame>().toEqualTypeOf<SessionAssistantStreamFrame>()
})
it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => {
const api = createFixtureApi()
const request = req({})
@@ -907,9 +894,14 @@ describe('createFixtureApi', () => {
expect(types).toContain('user/message')
expect(types).toContain('assistant/message')
expect(assistantFrames.some(frame => frame.type === 'chunk')).toBe(true)
expect(assistantFrames.some(frame => frame.type === 'end'
const committed = assistantFrames.find(frame => frame.type === 'end'
&& frame.outcome.kind === 'committed'
&& frame.outcome.eventType === 'assistant/message')).toBe(true)
&& frame.outcome.eventType === 'assistant/message')
expect(committed).toMatchObject({
type: 'end',
index: assistantFrames.filter(frame => frame.type === 'chunk'
&& frame.attemptId === committed?.attemptId).length,
})
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.
@@ -1603,7 +1595,7 @@ describe('createFixtureApi', () => {
const streamed = collectValues(api.sessionRemote.follow(sid('fx-alpha'), abort.signal), abort, frames => frames.some(frame => (
frame.type === 'assistant-stream'
&& frame.frame.type === 'chunk'
&& frame.frame.chunk.type === 'reasoning-delta'
&& isReasoningDeltaChunk(frame.frame.chunk)
&& frame.frame.chunk.text.includes('REASONING_STRESS_COMPLETE')
)))
const marker = hooks.startReasoningChunkStorm('fx-alpha', 3, 2, 16)
@@ -1622,7 +1614,7 @@ describe('createFixtureApi', () => {
const deltas = frames.flatMap(frame => (
frame.type === 'assistant-stream'
&& frame.frame.type === 'chunk'
&& frame.frame.chunk.type === 'reasoning-delta'
&& isReasoningDeltaChunk(frame.frame.chunk)
? [frame.frame.chunk.text]
: []
))
+1 -1
View File
@@ -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|timeout|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|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
@@ -11,6 +11,8 @@ import {
type ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
@@ -84,7 +86,64 @@ function at(
}
function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
return [...entries]
const output: SessionEventLikeEntry[] = []
let active: {
readonly turn: number
readonly step: number
readonly stream: AssistantStreamAccumulator
last: SessionLiveEventEntry
} | undefined
const flush = (): void => {
if (active === undefined) return
output.push(at(active.last.event.seq, 'assistant/attempt', {
turn: active.turn,
step: active.step,
stream: active.stream.snapshot(),
}, { time: active.last.event.time }))
active = undefined
}
for (const entry of entries) {
const event = entry.event as unknown as {
readonly type: string
readonly time: number
readonly data: { readonly turn?: number; readonly step?: number; readonly chunk?: StreamChunk }
}
if (event.type === 'assistant/live-chunk'
&& event.data.turn !== undefined
&& event.data.step !== undefined
&& event.data.chunk !== undefined) {
if (active !== undefined && (active.turn !== event.data.turn || active.step !== event.data.step)) flush()
const current = active ?? {
turn: event.data.turn,
step: event.data.step,
stream: new AssistantStreamAccumulator(),
last: entry,
}
active = current
current.stream.push({ time: event.time, chunk: event.data.chunk })
current.last = entry
continue
}
const current = active
if (event.type === 'assistant/message'
&& current !== undefined
&& current.turn === event.data.turn
&& current.step === event.data.step) {
output.push({
...entry,
event: {
...entry.event,
data: { ...entry.event.data, stream: current.stream.snapshot() },
} as SessionEvent,
})
active = undefined
continue
}
flush()
output.push(entry)
}
flush()
return output
}
function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
@@ -135,6 +194,24 @@ function comparableSnapshot(value: ChatSnapshot) {
}
}
function withoutEmbeddedSequenceAnchors(value: unknown): unknown {
if (Array.isArray(value)) return value.map(withoutEmbeddedSequenceAnchors)
if (value instanceof Map) {
return new Map([...value].map(([key, entry]) => [key, withoutEmbeddedSequenceAnchors(entry)]))
}
if (typeof value !== 'object' || value === null) return value
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
key,
key === 'anchorSeq' || key === 'controlAnchorSeq' || key === 'processStartSeq'
? '<representation-owned-seq>'
: withoutEmbeddedSequenceAnchors(entry),
]))
}
function comparableEmbeddedSnapshot(value: ChatSnapshot): unknown {
return withoutEmbeddedSequenceAnchors(comparableSnapshot(value))
}
function textMessage(id: string, text: string) {
return {
id,
@@ -877,7 +954,7 @@ describe('built-in conversation node Definitions', () => {
})
})
it('folds packed Assistant runs to the same Chat and Turn Tail state as scalar deltas', () => {
it('folds packed Assistant runs to the same Chat content and Turn Tail state as scalar deltas', () => {
const runningHistory = [
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
@@ -917,12 +994,16 @@ describe('built-in conversation node Definitions', () => {
]
const scalar = assembler(runningHistory)
const packedHistory = packedInputs(runningHistory)
expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(0)
expect(packedHistory).toHaveLength(3)
const runningAttempt = packedHistory.at(-1)?.event
expect(runningAttempt?.type).toBe('assistant/attempt')
if (runningAttempt?.type !== 'assistant/attempt') throw new Error('expected packed running attempt')
expect(runningAttempt.data.stream.length).toBeGreaterThan(0)
const packed = assembler(packedHistory)
expect(comparableSnapshot(snapshot(packed))).toEqual(comparableSnapshot(snapshot(scalar)))
expect(comparableEmbeddedSnapshot(snapshot(packed))).toEqual(comparableEmbeddedSnapshot(snapshot(scalar)))
const running = node(snapshot(packed), 'assistant-step')
expect(running).toMatchObject({ anchorSeq: 6 })
expect(running).toMatchObject({ anchorSeq: 12 })
expect(running?.data).toMatchObject({
time: 1_004,
blocks: [
@@ -937,7 +1018,7 @@ describe('built-in conversation node Definitions', () => {
value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
value.flush()
}
expect(comparableSnapshot(snapshot(packed))).toEqual(comparableSnapshot(snapshot(scalar)))
expect(comparableEmbeddedSnapshot(snapshot(packed))).toEqual(comparableEmbeddedSnapshot(snapshot(scalar)))
expect(node(snapshot(packed), 'turn-tail')?.anchorSeq).toBe(12.2)
const partialHistory = [
@@ -947,7 +1028,7 @@ describe('built-in conversation node Definitions', () => {
]
const partialScalar = snapshot(assembler(partialHistory, true))
const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
expect(comparableSnapshot(partialPacked)).toEqual(comparableSnapshot(partialScalar))
expect(comparableEmbeddedSnapshot(partialPacked)).toEqual(comparableEmbeddedSnapshot(partialScalar))
expect(node(partialPacked, 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
expect(node(partialPacked, 'turn-tail')?.anchorSeq).toBe(12.2)
@@ -982,8 +1063,13 @@ describe('built-in conversation node Definitions', () => {
}, { surfaceOp: 'append' }),
]
const finalizedScalar = snapshot(assembler(finalizedHistory))
const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
expect(comparableSnapshot(finalizedPacked)).toEqual(comparableSnapshot(finalizedScalar))
const finalizedInputs = packedInputs(finalizedHistory)
expect(finalizedInputs.filter(input => input.event.type === 'assistant/attempt')).toHaveLength(1)
const finalizedMessage = finalizedInputs.find(input => input.event.type === 'assistant/message')?.event
if (finalizedMessage?.type !== 'assistant/message') throw new Error('expected packed final message')
expect(finalizedMessage.data.stream.length).toBeGreaterThan(0)
const finalizedPacked = snapshot(assembler(finalizedInputs))
expect(comparableEmbeddedSnapshot(finalizedPacked)).toEqual(comparableEmbeddedSnapshot(finalizedScalar))
const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
expect(finalNode?.timing?.firstTokenTime).toBe(1_999)
@@ -1004,8 +1090,12 @@ describe('built-in conversation node Definitions', () => {
}, { surfaceOp: 'append' }),
]
const namedToolScalar = snapshot(assembler(namedToolHistory))
const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
expect(comparableSnapshot(namedToolPacked)).toEqual(comparableSnapshot(namedToolScalar))
const namedToolInputs = packedInputs(namedToolHistory)
const namedToolMessage = namedToolInputs.find(input => input.event.type === 'assistant/message')?.event
if (namedToolMessage?.type !== 'assistant/message') throw new Error('expected packed named-tool message')
expect(namedToolMessage.data.stream.length).toBeGreaterThan(0)
const namedToolPacked = snapshot(assembler(namedToolInputs))
expect(comparableEmbeddedSnapshot(namedToolPacked)).toEqual(comparableEmbeddedSnapshot(namedToolScalar))
const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
expect(namedTool?.timing?.firstTokenTime).toBe(4_000)
})
@@ -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/client/ui-conversation/README.md
README.md: 3292c39477a051ac406f453e781376164333ee78
README.zh.md: 433b2e6c8b3580dadf5dd9e38eaea7b1ab7022a6
README.md: dabe5afda6554a62f5ca038952e5a02e5a2f712e
README.zh.md: c38ea211c5d8d0ea384cfa39eba2608571166c89
+1 -1
View File
@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it.
+1 -1
View File
@@ -27,7 +27,7 @@ kind: "package-reference"
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。
adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 则只接收标准 event,assembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久 event 与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。
@@ -1,4 +1,7 @@
import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
SessionAssistantSettlementEntry, SessionEventLikeEntry, SessionTransientEventEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
import type {
ConversationContextReader, ConversationLocationData, ConversationMatch,
ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext,
@@ -239,6 +242,47 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
return publication
}
/**
* Retire one Assistant attempt's transient matches and apply its optional durable settlement.
* @param attemptId - process-local attempt whose transient presentation ended.
* @param entry - durable message or attempt event committed for the stream.
* @returns highest requested publication cadence.
*/
settleAssistant(
attemptId: LlmAttemptId,
entry?: SessionAssistantSettlementEntry,
): ConversationPublication {
this.revised.clear()
const retired = [...this.inputs.values()].filter((candidate): candidate is SessionTransientEventEntry => (
candidate.type === 'transient'
&& candidate.event.data.attemptId === attemptId
))
const retiredSeqs = new Set(retired.map(candidate => candidate.event.seq))
const affected = new Set<InternalContext>()
for (const seq of retiredSeqs) {
this.inputs.delete(seq)
for (const context of this.contextsBySeq.get(seq) ?? []) affected.add(context)
this.contextsBySeq.delete(seq)
}
for (const context of affected) {
context.matches = context.matches.filter(match => !retiredSeqs.has(match.event.seq))
}
this.locationIndex.removeAssistantTransients(retired.map(candidate => candidate.event))
let publication: ConversationPublication = retired.length === 0 ? 'none' : 'immediate'
if (entry !== undefined && !this.inputs.has(entry.event.seq)) {
this.inputs.set(entry.event.seq, entry)
this.locationIndex.insertAssistantSettlement(entry.event)
const pending = new Map<string, PendingMatch[]>()
publication = maximumPublication(publication, this.collectInput(entry, pending))
this.applyPendingMatches(pending, affected)
}
this.replayContexts(affected)
if (this.replayRevisedDependents()) publication = 'immediate'
this.revised.clear()
return publication
}
/**
* Add an older page while preserving existing Context and view identities.
* @param entries - newly loaded older Events.
@@ -113,7 +113,14 @@ class BoundConversation implements ConversationBinding {
if (next === 'immediate' || publication === 'none') publication = next
}
this.publish(publication)
return
}
case 'settle-assistant':
this.publish(this.assembler.settleAssistant(
window.change.attemptId,
window.change.entry,
))
return
}
}
@@ -1,5 +1,5 @@
import {
type SessionEventLike, type SessionEventLikeEntry,
type AssistantLiveChunkEvent, type SessionEventLike, type SessionEventLikeEntry,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -525,6 +525,38 @@ export class ConversationLocationIndex {
this.locations.set(event.seq, this.resolve(event.seq))
}
/**
* Remove indexed Assistant transients without rebuilding the Turn/Step timeline.
* @param events - transient events retired by one Assistant settlement.
*/
removeAssistantTransients(events: readonly AssistantLiveChunkEvent[]): void {
for (const event of events) {
const turn = this.coordinates.get(event.seq)?.turn
if (turn !== undefined) {
const seqs = this.seqsByTurn.get(turn)
seqs?.delete(event.seq)
if (seqs?.size === 0) this.seqsByTurn.delete(turn)
}
this.coordinates.delete(event.seq)
this.locations.delete(event.seq)
}
}
/**
* Index one durable Assistant settlement inserted before an already visible tail.
* @param event - message or attempt settlement with explicit Turn and Step coordinates.
*/
insertAssistantSettlement(
event: SessionEvent<'assistant/message'> | SessionEvent<'assistant/attempt'>,
): void {
this.coordinates.set(event.seq, {
turn: event.data.turn,
step: event.data.step,
})
this.indexTurnSeq(event.data.turn, event.seq)
this.locations.set(event.seq, this.resolve(event.seq))
}
private indexTurnSeq(turn: number, seq: number): void {
const current = this.seqsByTurn.get(turn) ?? new Set<number>()
current.add(seq)
@@ -509,6 +509,74 @@ describe('ConversationNodeAssembler', () => {
))).toHaveLength(1)
})
it('settles one Assistant attempt without replacing unrelated Contexts', () => {
interface State { readonly events: readonly string[] }
const definition: ConversationNodeDefinition<State> = {
kind: 'assistant-settlement',
target: 'test',
match: (event) => {
if (event.type === 'step/start') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
}
if (event.type === 'assistant/live-chunk'
|| event.type === 'assistant/message'
|| event.type === 'assistant/attempt'
|| event.type === 'llm/retry') {
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
}
return null
},
start: () => ({ events: [] }),
update: (context, match) => ({
events: [...context.state.events, match.event.type],
}),
buildViewNode: context => context.state === undefined
? null
: node(context, context.state.events),
}
const apply = vi.fn()
const assembler = new ConversationNodeAssembler(
new TestEventDefinitions([definition]),
new TestViewDefinitions([testView(apply)]),
)
const firstStart = input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 }))
const secondStart = input(at(SessionSeq(11), 'step/start', { turn: 2, step: 4 }))
const delta = transientChunk(11.5, 2, 3, { type: 'text-delta', index: 0, text: 'abc' })
const later = input(at(SessionSeq(13), 'llm/retry', { turn: 2, step: 3 }))
assembler.replaceWindow([firstStart, secondStart, delta, later], false)
assembler.flush()
const before = [...testSnapshot(assembler)?.nodes.values() ?? []]
const unaffected = before.find(candidate => candidate.id === '2:4')
expect(unaffected).toBeDefined()
apply.mockClear()
const settlementEvent = at(SessionSeq(12), 'assistant/message', {
turn: 2,
step: 3,
message: { role: 'assistant', content: [], source: { kind: 'model', provider: 'p', model: 'm' } },
stream: [],
})
if (settlementEvent.type !== 'assistant/message') throw new Error('expected Assistant settlement')
const settlement = { type: 'event' as const, event: settlementEvent }
expect(assembler.settleAssistant(LlmAttemptId('test-attempt'), settlement)).toBe('immediate')
assembler.flush()
const after = [...testSnapshot(assembler)?.nodes.values() ?? []]
expect(after.find(candidate => candidate.id === '2:3')?.data)
.toEqual(['assistant/message', 'llm/retry'])
expect(after.find(candidate => candidate.id === '2:4')).toBe(unaffected)
expect(apply).toHaveBeenCalledOnce()
expect(apply.mock.calls[0]?.[0]).toHaveLength(1)
assembler.append(transientChunk(13.5, 2, 3, { type: 'reasoning-delta', index: 0, text: 'x' }))
assembler.flush()
expect(assembler.settleAssistant(LlmAttemptId('test-attempt'))).toBe('immediate')
assembler.flush()
expect([...testSnapshot(assembler)?.nodes.values() ?? []]
.find(candidate => candidate.id === '2:3')?.data)
.toEqual(['assistant/message', 'llm/retry'])
})
it('replays one pending transient Match after prepend supplies its durable start', () => {
const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
const updates = vi.fn((
@@ -1,8 +1,8 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createAssistantMessage, LlmAttemptId } from '@deepseek-ai/dsh-llm'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
createScope, MutableSessionEventSource,
@@ -237,12 +237,24 @@ describe('Conversation registries', () => {
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'c' },
},
})
append({
seq: SessionSeq(5),
time: 5,
type: 'assistant/message',
data: { turn: 1, step: 1, message: { id: 'a1', role: 'assistant', content: [] }, stream: [] },
} as SessionEvent)
source.settleAssistant(LlmAttemptId('frame-probe'), {
type: 'event',
event: {
seq: SessionSeq(5),
time: 5,
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [],
source: { provider: 'test', model: 'test' },
}),
stream: [],
},
surfaceOp: 'append',
},
})
expect(cancelFrame).toHaveBeenCalledWith(4)
expect(frames).toHaveLength(0)
expect(listener).toHaveBeenCalledTimes(2)
@@ -8,6 +8,8 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationNodeAssembler, inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
@@ -73,7 +75,64 @@ function at(
}
function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
return [...entries]
const output: SessionEventLikeEntry[] = []
let active: {
readonly turn: number
readonly step: number
readonly stream: AssistantStreamAccumulator
last: SessionLiveEventEntry
} | undefined
const flush = (): void => {
if (active === undefined) return
output.push(at(active.last.event.seq, 'assistant/attempt', {
turn: active.turn,
step: active.step,
stream: active.stream.snapshot(),
}, { time: active.last.event.time }))
active = undefined
}
for (const entry of entries) {
const event = entry.event as unknown as {
readonly type: string
readonly time: number
readonly data: { readonly turn?: number; readonly step?: number; readonly chunk?: StreamChunk }
}
if (event.type === 'assistant/live-chunk'
&& event.data.turn !== undefined
&& event.data.step !== undefined
&& event.data.chunk !== undefined) {
if (active !== undefined && (active.turn !== event.data.turn || active.step !== event.data.step)) flush()
const current = active ?? {
turn: event.data.turn,
step: event.data.step,
stream: new AssistantStreamAccumulator(),
last: entry,
}
active = current
current.stream.push({ time: event.time, chunk: event.data.chunk })
current.last = entry
continue
}
const current = active
if (event.type === 'assistant/message'
&& current !== undefined
&& current.turn === event.data.turn
&& current.step === event.data.step) {
output.push({
...entry,
event: {
...entry.event,
data: { ...entry.event.data, stream: current.stream.snapshot() },
} as SessionEvent,
})
active = undefined
continue
}
flush()
output.push(entry)
}
flush()
return output
}
function assembler(events: readonly SessionEventLikeEntry[]): ConversationNodeAssembler {
@@ -202,7 +261,11 @@ describe('Trajectory conversation Definitions', () => {
]
const runningScalar = snapshot(assembler(runningHistory))
const packedHistory = packedInputs(runningHistory)
expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(0)
expect(packedHistory).toHaveLength(3)
const runningAttempt = packedHistory.at(-1)?.event
expect(runningAttempt?.type).toBe('assistant/attempt')
if (runningAttempt?.type !== 'assistant/attempt') throw new Error('expected packed running attempt')
expect(runningAttempt.data.stream.length).toBeGreaterThan(0)
const runningPacked = snapshot(assembler(packedHistory))
expect(runningPacked).toEqual(runningScalar)
expect(runningPacked.partial?.blocks).toEqual([
@@ -263,7 +326,12 @@ describe('Trajectory conversation Definitions', () => {
at(31, 'step/end', { turn: 2, step: 1 }),
]
const finalizedScalar = snapshot(assembler(finalizedHistory))
const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
const finalizedInputs = packedInputs(finalizedHistory)
expect(finalizedInputs.filter(input => input.event.type === 'assistant/attempt')).toHaveLength(1)
const finalizedMessage = finalizedInputs.find(input => input.event.type === 'assistant/message')?.event
if (finalizedMessage?.type !== 'assistant/message') throw new Error('expected packed final message')
expect(finalizedMessage.data.stream.length).toBeGreaterThan(0)
const finalizedPacked = snapshot(assembler(finalizedInputs))
expect(finalizedPacked).toEqual(finalizedScalar)
expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
timing: { firstTokenTime: 3_000 },
@@ -291,7 +359,11 @@ describe('Trajectory conversation Definitions', () => {
}),
]
const namedToolScalar = snapshot(assembler(namedToolHistory))
const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
const namedToolInputs = packedInputs(namedToolHistory)
const namedToolMessage = namedToolInputs.find(input => input.event.type === 'assistant/message')?.event
if (namedToolMessage?.type !== 'assistant/message') throw new Error('expected packed named-tool message')
expect(namedToolMessage.data.stream.length).toBeGreaterThan(0)
const namedToolPacked = snapshot(assembler(namedToolInputs))
expect(namedToolPacked).toEqual(namedToolScalar)
expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
timing: { firstTokenTime: 4_000 },
@@ -709,13 +709,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionAreaProps',
declaration: 'export interface SessionAreaProps {\n empty?: (() => ReactNode) | undefined;\n children: ReactNode;\n}',
},
{
name: 'SessionAssistantSettlementEntry',
declaration: 'export interface SessionAssistantSettlementEntry {\n readonly type: \'event\';\n readonly event: SessionEvent<\'assistant/message\'> | SessionEvent<\'assistant/attempt\'>;\n}',
},
{
name: 'SessionBinding',
declaration: 'export interface SessionBinding {\n readonly sessionId: SessionId;\n readonly session: SessionFace;\n readonly eventSource: SessionEventSource;\n readonly ctx: AgentContext;\n}',
},
{
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 SessionEventLikeEntry[];\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} | {\n readonly kind: \'settle-assistant\';\n readonly attemptId: LlmAttemptId;\n readonly entry?: SessionAssistantSettlementEntry;\n};',
},
{
name: 'SessionEventLikeEntry',
+15 -8
View File
@@ -1,6 +1,6 @@
/** Lossless compact representation of one model-stream attempt. */
import { deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
import { assertNever, deepFreeze, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
import type { ToolCallId } from './brand.ts'
import type { StreamChunk } from './types.ts'
@@ -113,15 +113,17 @@ export class AssistantStreamAccumulator {
}
case 'tool-call-delta': {
safeIndex(chunk.index, chunk.type)
if (typeof chunk.id !== 'string' || chunk.id.length === 0) {
throw new TypeError('tool-call-delta id must be a non-empty string')
}
if (Object.hasOwn(chunk, 'name') && (typeof chunk.name !== 'string' || chunk.name.length === 0)) {
throw new TypeError('tool-call-delta name must be a non-empty string')
if (typeof chunk.id !== 'string') throw new TypeError('tool-call-delta id must be a string')
if (Object.hasOwn(chunk, 'name') && typeof chunk.name !== 'string') {
throw new TypeError('tool-call-delta name must be a string')
}
if (typeof chunk.argumentsDelta !== 'string') {
throw new TypeError('tool-call-delta argumentsDelta must be a string')
}
if (chunk.id.length === 0 || chunk.name === '') {
this.records.push({ type: 'chunk', time, chunk })
return timed
}
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')
@@ -148,10 +150,15 @@ export class AssistantStreamAccumulator {
}
return timed
}
default:
case 'block-start':
case 'block-end':
case 'usage':
case 'finish':
this.records.push({ type: 'chunk', time, chunk })
return timed
default:
return assertNever(chunk, 'AssistantStreamAccumulator.push')
}
return timed
}
/**
@@ -131,7 +131,7 @@ describe('AssistantStreamAccumulator', () => {
])
})
it('rejects unsafe admission before mutating the accumulator', () => {
it('rejects unsafe values while preserving JSON-safe empty adapter identities', () => {
const accumulator = new AssistantStreamAccumulator()
expect(() => accumulator.push({
time: 0.5,
@@ -149,23 +149,40 @@ describe('AssistantStreamAccumulator', () => {
time: 1,
chunk: { type: 'text-delta', index: 0, text: 1 } as never,
})).toThrow(/text must be a string/)
expect(() => accumulator.push({
expect(accumulator.push({
time: 1,
chunk: { type: 'tool-call-delta', index: 0, id: ToolCallId(''), argumentsDelta: '{}' },
})).toThrow(/id/)
expect(() => accumulator.push({
}).chunk).toMatchObject({ id: '' })
expect(accumulator.push({
time: 1,
chunk: {
type: 'tool-call-delta', index: 0, id: ToolCallId('call'), name: '', argumentsDelta: '{}',
},
})).toThrow(/name/)
}).chunk).toMatchObject({ name: '' })
expect(() => accumulator.push({
time: 1,
chunk: {
type: 'tool-call-delta', index: 0, id: ToolCallId('call'), argumentsDelta: 1,
} as never,
})).toThrow(/argumentsDelta must be a string/)
expect(accumulator.snapshot()).toStrictEqual([])
expect(() => accumulator.push({
time: 1,
chunk: { type: 'future' } as never,
})).toThrow(/unreachable variant in AssistantStreamAccumulator\.push/)
expect(accumulator.snapshot()).toStrictEqual([
{
type: 'chunk', time: 1,
chunk: { type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
},
{
type: 'chunk', time: 1,
chunk: { type: 'tool-call-delta', index: 0, id: 'call', name: '', argumentsDelta: '{}' },
},
])
expect(expandAssistantStream(accumulator.snapshot()).map(member => member.chunk)).toStrictEqual([
{ type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' },
{ type: 'tool-call-delta', index: 0, id: 'call', name: '', argumentsDelta: '{}' },
])
})
it.each([
@@ -35,17 +35,19 @@ describe('first-party Session format catalog', () => {
})
})
it('restores exact current payloads while retaining explicitly ignorable extensions', () => {
it('restores the installed current vocabulary without freezing ordinary payload additions', () => {
const header = {
type: 'session', version: 2, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
}
expect(() => sessionFormatCatalog.decodeArtifact(header, [{
const extended = sessionFormatCatalog.decodeArtifact(header, [{
type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
}])).toThrow(/unexpected field postReleaseMember/)
}])
expect(sessionFormatCatalog.migrate(extended).events).toEqual(extended.events)
expect(() => sessionFormatCatalog.decodeArtifact(header, [{
const unknownRequired = sessionFormatCatalog.decodeArtifact(header, [{
type: 'ordinary/not-installed', seq: 0, time: 1, data: 'future',
}])).toThrow(/unknown event type/)
}])
expect(() => sessionFormatCatalog.migrate(unknownRequired)).toThrow(/unknown event type/)
const extension = sessionFormatCatalog.decodeArtifact(header, [{
type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true,
@@ -13,6 +13,7 @@ export interface ReleasedV0PayloadDisposition {
* @param required - members that must be present.
* @param optional - additional admitted members.
* @param opaque - members retained as lossless JSON without nested semantic inspection.
* @param extensionArms - nested discriminant paths that admit owner-defined variants.
* @returns the detached frozen disposition.
*/
export function defineReleasedPayloadDisposition(
@@ -27,11 +27,21 @@ interface ToolLifecycle {
state: 'advertised' | 'started'
}
/** Relationship roles added by a later format while reusing the released validator. */
export interface ReleasedRelationshipExtensions {
/** Event types that must occur inside the current open step. */
readonly stepEvents?: ReadonlySet<string>
}
/**
* Validate cross-event relationships required to construct one current Session safely.
* @param artifact - complete normalized v0 or exact current v1 artifact.
* @param extensions - later-generation event roles interpreted by the calling format owner.
*/
export function assertReleasedArtifactRelationships(artifact: SessionFormatArtifact): void {
export function assertReleasedArtifactRelationships(
artifact: SessionFormatArtifact,
extensions: ReleasedRelationshipExtensions = {},
): void {
let openTurn: number | null = null
let openStep: number | null = null
let openStepProvider: string | undefined
@@ -48,14 +58,18 @@ export function assertReleasedArtifactRelationships(artifact: SessionFormatArtif
const commandRuns = new Set<string>()
for (const event of artifact.events) {
if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined
&& event.type !== 'assistant/attempt') continue
const extensionStepEvent = extensions.stepEvents?.has(event.type) === true
if (RELEASED_V0_EVENT_DISPOSITIONS[event.type] === undefined && !extensionStepEvent) 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')
&& openCompaction !== undefined && !staleCompactionStarts.has(openCompaction.startSeq)) {
throw new SessionFormatError(`${event.type} crosses an open compaction`)
}
if (extensionStepEvent) {
requireOpenStep(event, data, openTurn, openStep)
continue
}
switch (event.type) {
case 'turn/start':
@@ -92,7 +106,6 @@ export function assertReleasedArtifactRelationships(artifact: SessionFormatArtif
nextStep += 1
break
case 'assistant/chunk':
case 'assistant/attempt':
requireOpenStep(event, data, openTurn, openStep)
break
case 'assistant/message': {
@@ -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-v1-to-v2/README.md
README.md: cff1ebe186e2211247501051da2020a2af673cb9
README.zh.md: 0acb26502c211c88d72ca2579430ec1641a3ac5f
README.md: 5ead8532b2cce52a0cbf4c39bfe9e54ef14002f1
README.zh.md: 1f41d24007f95f799a9667910acb93d494a2aa2e
@@ -42,15 +42,15 @@ A successful v1 `assistant/message` must cite its complete ordered attempt. The
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 migration-target validation rejects unknown event types, while current restoration retains installed extensions and unknown events carrying `ignorable: true`. Both paths reject unexpected members, malformed compact streams, and disagreement between a non-empty stream and its assembled message, usage, or replay state.
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, range-encodes only `sourceEventSeqs`, and remains neutral to ordinary event vocabulary and payload growth. Strict migration-target validation freezes the released-v2 inventory and rejects unknown types or members. Current restoration instead admits event types known to the installed Session package plus unknown events carrying `ignorable: true`, then delegates payload and stream semantics to the installed current restorer. All paths retain strict header, event-envelope, sequence, and inherited-cut validation.
### Measure current-read acceptance
### Measure catalog-dispatch overhead
```text
pnpm run benchmark:session-format-v1-to-v2
```
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.
The manual acceptance runs three repetitions with 100 warmup pairs and 600 alternating measured pairs per case. It measures static catalog-routing overhead against direct released-v2 restoration of the same already parsed physical rows, requiring every pooled median and p95 regression to stay within 5%. It neither compares v1 with v2 nor times backend I/O; representation sizes and absolute migration/replay costs are reported separately without a speedup claim. Add `--smoke` only for a short correctness and reporting pass; smoke timing is non-gating and is not an acceptance result.
-----
@@ -66,9 +66,9 @@ The edge first groups v1 chunks by turn, step, terminal finish, and explicit mes
|---|---|
| [`src/migration.ts`](src/migration.ts) | Attempt grouping, settlement substitution, dense sequence mapping, and reference rewriting |
| [`src/codec.ts`](src/codec.ts) | Released-v2 header, one-event-per-row encoding, provenance ranges, and recoverable prefix decoding |
| [`src/validation.ts`](src/validation.ts) | Exact v2 header, envelope, payload, stream, relationship, and seed-cut validation |
| [`src/validation.ts`](src/validation.ts) | Physical v2 envelope/cut validation, exact migration-target policy, and vocabulary-neutral current restoration |
| [`src/dispositions.ts`](src/dispositions.ts) | Frozen released-v2 event and payload-member inventory |
| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | Repository-only current-read, migration, token-meter, size, and memory acceptance report |
| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | Repository-only catalog-dispatch, migration, token-meter, size, and memory acceptance report |
</details>
@@ -42,15 +42,15 @@ const migratedV2 = sessionFormatV1ToV2.migrate(decodedV1)
如果引用指向被消费的 chunk,迁移会失败,而不会把它重定向到语义不同的事件。它会重映射已声明的事件 provenance、surface replacement、command source event、compaction range 与 list,以及 title message list。带 seed 的源若让继承切点切开一个 Assistant attempt,也会迁移失败;目标会用 `session/end-seed { inherited: true }` 标出精确切点。
v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,并且只对 `sourceEventSeqs` 做范围编码。严格的迁移目标校验会拒绝未知事件类型;当前恢复则保留已安装的扩展,以及携带 `ignorable: true` 的未知事件。两条路径都会拒绝意外成员、格式错误的紧凑 stream,以及非空 stream 与组装后的 message、usage 或 replay state 之间的不一致。
v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,只对 `sourceEventSeqs` 做范围编码,并对普通事件词汇与 payload 扩展保持中立。严格的迁移目标校验会冻结 released-v2 清单并拒绝未知 type 或 member。当前恢复则准入 installed Session package 已知的事件 type,以及携带 `ignorable: true` 的未知事件,再把 payload 与 stream 语义交给 installed current restorer。所有路径仍严格校验 header、event envelope、sequence 与 inherited cut。
### 测量当前读取验收
### 测量 catalog dispatch 开销
```text
pnpm run benchmark:session-format-v1-to-v2
```
手工 acceptance 会运行三轮,每个 case 使用 100 组 warmup pair 与 600 组交替测量 pair。它把当前 v2 catalog-dispatch 读取与相同 backend、id、file 和 decoder 的 direct-current 读取比较,并要求每个 pooled median 与 p95 regression 都保持在 5% 以内。只在需要较短的正确性与报告检查时添加 `--smoke`;smoke timing 不参与 gate,也不是 acceptance 结果。
手工 acceptance 会运行三轮,每个 case 使用 100 组 warmup pair 与 600 组交替测量 pair。它针对同一批已经解析的物理 row,把静态 catalog routing 与直接 released-v2 restoration 比较,并要求每个 pooled median 与 p95 regression 都保持在 5% 以内。它既不比较 v1 与 v2,也不计入 backend I/O;representation size 与 migration/replay 绝对成本会单独报告,且不声称加速。只在需要较短的正确性与报告检查时添加 `--smoke`;smoke timing 不参与 gate,也不是 acceptance 结果。
-----
@@ -66,9 +66,9 @@ pnpm run benchmark:session-format-v1-to-v2
|---|---|
| [`src/migration.ts`](src/migration.ts) | Attempt 分组、settlement 替换、密集序号映射与引用重写 |
| [`src/codec.ts`](src/codec.ts) | 已发布 v2 header、每行一个事件的编码、provenance 范围与可恢复前缀解码 |
| [`src/validation.ts`](src/validation.ts) | 精确的 v2 header、信封、payload、stream、关系与 seed 切点校验 |
| [`src/validation.ts`](src/validation.ts) | v2 物理 envelope/cut 校验、精确 migration-target 策略与 vocabulary-neutral current restoration |
| [`src/dispositions.ts`](src/dispositions.ts) | 冻结的已发布 v2 事件与 payload 成员清单 |
| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | 仅用于仓库的 current-read、migration、token-meter、size 与 memory acceptance report |
| [`scripts/benchmark-session-format-v1-to-v2.ts`](../../../scripts/benchmark-session-format-v1-to-v2.ts) | 仅用于仓库的 catalog-dispatch、migration、token-meter、size 与 memory acceptance report |
</details>
@@ -13,13 +13,10 @@ import type {
SessionFormatJsonObject,
SessionFormatJsonValue,
} from '@deepseek-ai/dsh-session-format'
import { RELEASED_V2_EVENT_TYPES } from './dispositions.ts'
import { assertReleasedV2Header, restoreReleasedV2Artifact } from './validation.ts'
import { assertReleasedV2Header, assertReleasedV2PhysicalArtifact } from './validation.ts'
const HEADER_REQUIRED = ['type', 'version', 'id', 'createdAt', 'isSeeded', 'delegationDepth'] as const
const HEADER_OPTIONAL = ['cwd', 'parentSession', 'origin', 'agentPreset'] as const
const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
/** Frozen physical JSON codec for released v2. */
export const releasedV2SessionFormatCodec = Object.freeze({
version: 2,
@@ -110,7 +107,7 @@ function decodeArtifact(
}
const inheritedEventCount = deriveInheritedEventCount(header, events)
const artifact = snapshotSessionFormatArtifact({ header, inheritedEventCount, events }, 'released v2 artifact')
restoreReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET)
assertReleasedV2PhysicalArtifact(artifact)
return artifact
}
@@ -142,7 +139,7 @@ function deriveInheritedEventCount(header: SessionFormatHeader, events: readonly
}
function encodeArtifact(artifact: SessionFormatArtifact): EncodedSessionFormatArtifact {
restoreReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET)
assertReleasedV2PhysicalArtifact(artifact)
const header = artifact.header
const physicalHeader = snapshotSessionFormatJson({
type: 'session',
@@ -147,6 +147,8 @@ function collectAttemptGroups(events: readonly SessionFormatEvent[]): readonly A
continue
}
if (sources.length === 0) {
// Released v1 uses an explicit empty list to state that this message
// owns no preceding chunks; an absent list cannot make that claim.
groups.push({ turn, step, chunks: [], terminal: true, messageSeq: event.seq })
continue
}
@@ -29,6 +29,9 @@ const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result
const SURFACE_OPTIONAL = ['ignorable', 'sourceEventSeqs', 'surfaceOp'] as const
const LOG_OPTIONAL = ['ignorable'] as const
const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
const RELEASED_V2_RELATIONSHIP_EXTENSIONS = {
stepEvents: new Set(['assistant/attempt']),
} as const
/**
* Validate the exact logical header written by released v2.
@@ -63,13 +66,22 @@ export function assertReleasedV2Header(header: SessionFormatHeader): void {
* @throws {SessionFormatUnsupportedMigrationError} when the artifact contains an unknown event type.
*/
export function assertReleasedV2Artifact(artifact: SessionFormatArtifact): void {
validateReleasedV2Artifact(artifact, RELEASED_V2_EVENT_TYPE_SET, false)
validateReleasedV2Artifact(artifact, 'target', RELEASED_V2_EVENT_TYPE_SET)
}
/**
* Validate only the released-v2 physical header, event envelopes, and inherited cut.
* Event vocabulary and payload semantics belong to target or installed-current restoration.
* @param artifact - complete physical-codec output.
*/
export function assertReleasedV2PhysicalArtifact(artifact: SessionFormatArtifact): void {
validateReleasedV2Artifact(artifact, 'physical')
}
function validateReleasedV2Artifact(
artifact: SessionFormatArtifact,
knownEventTypes: ReadonlySet<string>,
allowIgnorableUnknown: boolean,
mode: 'target' | 'current' | 'physical',
knownEventTypes?: ReadonlySet<string>,
): void {
assertReleasedV2Header(artifact.header)
const cut = sessionFormatCount(artifact.inheritedEventCount, 'format v2 inherited event count')
@@ -81,17 +93,17 @@ function validateReleasedV2Artifact(
const type = record['type']
if (typeof type !== 'string') throw new SessionFormatError(`format v2 event ${index} type must be a string`)
const disposition = RELEASED_V2_EVENT_DISPOSITIONS[type]
const installed = knownEventTypes.has(type)
const installed = knownEventTypes?.has(type) === true
const ignorableUnknown = disposition === undefined
&& allowIgnorableUnknown
&& mode === 'current'
&& record['ignorable'] === true
if (disposition === undefined && !installed && !ignorableUnknown) {
if (mode !== 'physical' && disposition === undefined && !installed && !ignorableUnknown) {
throw new SessionFormatUnsupportedMigrationError(
`format v2 contains unknown event type ${JSON.stringify(type)} at seq ${index}`,
)
}
const surface = disposition !== undefined && SURFACE_TYPES.has(type)
const optional = disposition === undefined
const optional = mode === 'physical' || disposition === undefined
? SURFACE_OPTIONAL
: surface ? SURFACE_OPTIONAL : LOG_OPTIONAL
exactKeys(record, EVENT_REQUIRED, optional, `format v2 event ${index}`)
@@ -100,8 +112,8 @@ function validateReleasedV2Artifact(
if (record['ignorable'] !== undefined && record['ignorable'] !== true) {
throw new SessionFormatError(`format v2 event ${index} ignorable must be true when present`)
}
if (surface) assertReleasedSurfaceMetadata(record, index, type, 'forbid-assistant')
if (disposition !== undefined) assertPayload(event, disposition)
if (mode === 'target' && surface) assertReleasedSurfaceMetadata(record, index, type, 'forbid-assistant')
if (mode === 'target' && disposition !== undefined) assertPayload(event, disposition)
if (type === 'session/end-seed') {
const data = jsonRecord(event.data, `session/end-seed ${index} data`)
if (data['inherited'] === true) lastInheritedMarker = index
@@ -113,7 +125,9 @@ function validateReleasedV2Artifact(
if (!artifact.header.isSeeded && lastInheritedMarker !== undefined) {
throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker')
}
assertReleasedArtifactRelationships(artifact)
if (mode === 'target') {
assertReleasedArtifactRelationships(artifact, RELEASED_V2_RELATIONSHIP_EXTENSIONS)
}
}
function assertPayload(
@@ -201,6 +215,6 @@ export function restoreReleasedV2Artifact(
artifact: SessionFormatArtifact,
knownEventTypes: ReadonlySet<string>,
): SessionFormatArtifact {
validateReleasedV2Artifact(artifact, knownEventTypes, true)
validateReleasedV2Artifact(artifact, 'current', knownEventTypes)
return artifact
}
@@ -126,10 +126,12 @@ describe('releasedV2SessionFormatCodec rows', () => {
expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
})
it('round-trips an unknown ignorable event for current-build restoration', () => {
const source = artifact([{
type: 'external/ignorable', seq: 0, time: 1, data: { retained: true }, ignorable: true,
}])
it('keeps the v2 physical codec vocabulary-neutral for current growth and a future source freeze', () => {
const source = artifact([
{ type: 'external/required', seq: 0, time: 1, data: { retained: true } },
{ type: 'external/ignorable', seq: 1, time: 2, data: { retained: true }, ignorable: true },
{ type: 'turn/start', seq: 2, time: 3, data: { turn: 1, postReleaseMember: true } },
])
const encoded = releasedV2SessionFormatCodec.encodeArtifact(source)
@@ -416,6 +416,9 @@ describe('released v2 restoration seam', () => {
installedExtension,
new Set([...RELEASED_V2_EVENT_TYPES, 'external/installed']),
)).toBe(installedExtension)
const extendedPayload = artifact([event('turn/start', 0, { turn: 1, postReleaseMember: true })])
expect(restoreReleasedV2Artifact(extendedPayload, new Set(RELEASED_V2_EVENT_TYPES)))
.toBe(extendedPayload)
expect(() => restoreReleasedV2Artifact(
artifact([event('external/unknown', 0, {})]),
new Set(RELEASED_V2_EVENT_TYPES),
@@ -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-log-deepseek/README.md
README.md: eea3cc5abe2b02d20a5aba9ad9b33dbc2a290196
README.zh.md: 96b5256a050db26f5cf90b927ef9f847a13cbf14
README.md: 29e6b7860b2654f4ff272c2e9d23d3490527e396
README.zh.md: ec104bf51c7d9e4589fa19f3fbba2ecc762b1889
@@ -34,7 +34,7 @@ Shipped profiles mount the plugin so an overlay can enable it, but the default c
<a id="request-field"></a>
## Request field
For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session format generation, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-1 field contains `sessionFormatVersion`, a raw session header with the `isSeeded` lineage bit, numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because both the recorded Session id and format generation must match the request source.
For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session format generation, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-2 field contains `sessionFormatVersion`, a raw session header with the `isSeeded` lineage bit, numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because both the recorded Session id and format generation must match the request source.
<a id="acceptance-and-retry"></a>
## Acceptance and retry
@@ -34,7 +34,7 @@ kind: "package-reference"
<a id="request-field"></a>
## 请求字段
对于携带存活 `sessionId` 的请求,插件会折叠该确切会话格式代的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 1 字段包含 `sessionFormatVersion`、带 `isSeeded` 谱系位的原始会话 header、数值型 `afterSeq` 与 `throughSeq`,以及把完整权威事件翻译为原始数值 envelope 字段后的数组元素。只有记录的会话 id 与格式代均匹配请求来源时水位才生效,因此 fork 会话会忽略从父会话继承的水位。
对于携带存活 `sessionId` 的请求,插件会折叠该确切会话格式代的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 2 字段包含 `sessionFormatVersion`、带 `isSeeded` 谱系位的原始会话 header、数值型 `afterSeq` 与 `throughSeq`,以及把完整权威事件翻译为原始数值 envelope 字段后的数组元素。只有记录的会话 id 与格式代均匹配请求来源时水位才生效,因此 fork 会话会忽略从父会话继承的水位。
<a id="acceptance-and-retry"></a>
## 接受与重试
@@ -147,7 +147,7 @@ export function apply(ctx: Context, config: Config): void {
if (throughSeq === undefined) return undefined
const suffix = session.snapshotEvents(SessionLogOffset(afterSeq + 1))
const value: DeepSeekSessionLogExtension = {
version: 1,
version: 2,
sessionFormatVersion: session.header.version,
session: wireHeader(session),
afterSeq: Number(afterSeq),
@@ -35,7 +35,7 @@ export interface DeepSeekSessionLogWireEvent {
/** Versioned incremental session-log field carried by an official DeepSeek request. */
export interface DeepSeekSessionLogExtension {
readonly version: 1
readonly version: 2
/** Session format generation represented by this suffix. */
readonly sessionFormatVersion: number
readonly session: DeepSeekSessionLogWireHeader
@@ -82,6 +82,7 @@ describe('incremental DeepSeek session-log upload', () => {
const first = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: session.id })
const firstPayload = first.fields.dsh_session_log
expect(firstPayload).toMatchObject({
version: 2,
sessionFormatVersion: SESSION_FORMAT_VERSION,
afterSeq: -1,
throughSeq: 1,
@@ -38,6 +38,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
@@ -19,6 +19,7 @@ import {
import { runLiveWritePathContract } from '../../session-persistence/tests/live-write-contract.ts'
import { LIVE_WRITE_BATCH_MAX_DELAY_MS, type JsonlSessionHandle } from '../src/storage.ts'
import SessionStore from '@deepseek-ai/dsh-session'
import { releasedV1SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v0-to-v1'
const statRace = vi.hoisted(() => ({
path: undefined as string | undefined,
@@ -130,6 +131,40 @@ function releasedV0Header(header: SessionHeader): Record<string, unknown> {
}
}
function releasedV1PackedPhysicalLog(header: SessionHeader): string {
const source = releasedV1OneTurnLog()
const extraChunks: SessionEvent[] = [
{
type: 'assistant/chunk', seq: SessionSeq(5), time: 3,
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } },
} as unknown as SessionEvent,
{
type: 'assistant/chunk', seq: SessionSeq(6), time: 3,
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' } },
} as unknown as SessionEvent,
]
const events = [
...source.slice(0, 5),
...extraChunks,
...source.slice(5).map((event): SessionEvent => ({
...event,
seq: SessionSeq(event.seq + 2),
...(event.type === 'assistant/message'
? { sourceEventSeqs: [3, 4, 5, 6, 7, 8].map(SessionSeq) }
: {}),
})),
]
const encoded = releasedV1SessionFormatCodec.encodeArtifact({
header: { ...header, version: 1, delegationDepth: header.delegationDepth ?? 0 },
inheritedEventCount: 0,
events,
} as never, { packChunks: true })
if (!encoded.rows.some(row => row['type'] === 'text-chunks')) {
throw new Error('released v1 test fixture did not produce a packed text row')
}
return [encoded.header, ...encoded.rows].map(row => JSON.stringify(row)).join('\n') + '\n'
}
/** Create + append + close: persist one whole log through the write handle. */
async function writeLog(persistence: SessionPersistence, m: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
const handle = await persistence.create(m)
@@ -588,6 +623,50 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
.toEqual(['session.jsonl', 'session.v2.jsonl'])
})
it('publishes v2 beside an unchanged physical v1 source with packed chunk rows', async () => {
const header = meta('released-v1-read', '/work')
const sourcePath = generationLogPath(root, header.cwd, header.id, 1, 'none')
const currentPath = rawLogPath(root, header.cwd, header.id)
const source = Buffer.from(releasedV1PackedPhysicalLog(header))
await mkdir(dirname(sourcePath), { recursive: true })
await writeFile(sourcePath, source)
const restored = await readAll(ctx.sessionPersistence, header.id)
expect(restored.meta).toEqual({ ...header, delegationDepth: 0 })
expect(restored.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end',
])
expect(restored.events.find(event => event.type === 'assistant/message'))
.toMatchObject({ data: { message: { content: [{ type: 'text', text: 'hello' }] } } })
expect(await readFile(sourcePath)).toEqual(source)
expect(JSON.parse((await readFile(currentPath, 'utf8')).split('\n')[0] as string))
.toMatchObject({ id: header.id, version: SESSION_FORMAT_VERSION })
expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
.toEqual(['session.v1.jsonl', 'session.v2.jsonl'])
})
it('selects v1 from a v0/v1 directory, then v2 from the retained three-generation set', async () => {
const header = meta('mixed-generation-read', '/work')
const directory = sessionDir(root, header.cwd, header.id)
const v0Path = historicalLogPath(root, header.cwd, header.id)
const v1Path = generationLogPath(root, header.cwd, header.id, 1, 'none')
const v2Path = rawLogPath(root, header.cwd, header.id)
await mkdir(directory, { recursive: true })
await writeFile(v0Path, `${JSON.stringify(releasedV0Header(header))}\n`)
await writeFile(v1Path, releasedV1PackedPhysicalLog(header))
const migrated = await readAll(ctx.sessionPersistence, header.id)
expect(migrated.events.map(event => event.type)).toContain('assistant/message')
expect((await readdir(directory)).filter(name => name.startsWith('session')).sort())
.toEqual(['session.jsonl', 'session.v1.jsonl', 'session.v2.jsonl'])
await writeFile(v0Path, 'corrupt lower v0\n')
await writeFile(v1Path, 'corrupt lower v1\n')
await expect(readAll(ctx.sessionPersistence, header.id)).resolves.toEqual(migrated)
expect(await readFile(v2Path, 'utf8')).toContain('"version":2')
})
it('uses the same migration path for a handle storage resolution', async () => {
const header = meta('released-v0-handle-read', '/work')
const sourcePath = historicalLogPath(root, header.cwd, header.id)
@@ -454,64 +454,10 @@ export function normalizeSessionSnapshots(
* @returns the same records without delivery or captured-source generation qualifiers.
*/
export function normalizeSessionFormatProvenance(rawLog: string): string {
const lines = rawLog.split('\n')
const records = lines.map(line => line.trim().length === 0
? undefined
: JSON.parse(line) as Record<string, unknown>)
const inertEndSeeds = records.flatMap((record) => {
if (record?.type !== 'session/end-seed' || record.data === null
|| typeof record.data !== 'object' || Array.isArray(record.data)
|| (record.data as Record<string, unknown>).inherited === true) return []
return typeof record.seq === 'number' ? [record.seq] : []
}).sort((left, right) => left - right)
const remapSeq = (value: unknown): unknown => {
if (typeof value !== 'number' || !Number.isSafeInteger(value)) return value
if (inertEndSeeds.includes(value)) {
throw new Error(`Session snapshot comparison reference targets inert end-seed ${String(value)}`)
}
return value - inertEndSeeds.filter(seq => seq < value).length
}
const remapList = (value: unknown): unknown => Array.isArray(value) ? value.map(remapSeq) : value
return records.flatMap((record, index) => {
if (record === undefined) return [lines[index] as string]
if (record.type === 'session/end-seed' && record.data !== null
&& typeof record.data === 'object' && !Array.isArray(record.data)
&& (record.data as Record<string, unknown>).inherited !== true) return []
return rawLog.split('\n').map((line) => {
if (line.trim().length === 0) return line
const record = JSON.parse(line) as Record<string, unknown>
let changed = normalizeCapturedFormatProvenance(record)
const recordSeq = record.seq
if (typeof recordSeq === 'number' && inertEndSeeds.some(seq => seq < recordSeq)) {
record.seq = remapSeq(recordSeq)
changed = true
}
if (Array.isArray(record.sourceEventSeqs)) {
record.sourceEventSeqs = remapList(record.sourceEventSeqs)
changed = true
}
if (record.surfaceOp !== null && typeof record.surfaceOp === 'object' && !Array.isArray(record.surfaceOp)) {
const operation = record.surfaceOp as Record<string, unknown>
record.surfaceOp = { ...operation, start: remapSeq(operation.start), end: remapSeq(operation.end) }
changed = true
}
if (record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data)) {
const data = record.data as Record<string, unknown>
if (Object.hasOwn(data, 'sourceEventSeq')) {
record.data = { ...data, sourceEventSeq: remapSeq(data.sourceEventSeq) }
changed = true
} else if (Array.isArray(data.messageSeqs)) {
record.data = { ...data, messageSeqs: remapList(data.messageSeqs) }
changed = true
} else if (data.shadowedRange !== null && typeof data.shadowedRange === 'object'
&& !Array.isArray(data.shadowedRange)) {
const range = data.shadowedRange as Record<string, unknown>
record.data = {
...data,
shadowedRange: { ...range, start: remapSeq(range.start), end: remapSeq(range.end) },
shadowedSeqs: remapList(data.shadowedSeqs),
}
changed = true
}
}
if (record.type === 'session' && Object.hasOwn(record, 'version')) {
delete record.version
changed = true
@@ -519,14 +465,13 @@ export function normalizeSessionFormatProvenance(rawLog: string): string {
if (record.type === 'session-log-deepseek/delivery-accepted'
&& record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data)) {
const data = { ...record.data as Record<string, unknown> }
if (Object.hasOwn(data, 'sessionFormatVersion') || Object.hasOwn(data, 'throughSeq')) {
if (Object.hasOwn(data, 'sessionFormatVersion')) {
delete data.sessionFormatVersion
delete data.throughSeq
record.data = data
changed = true
}
}
return [changed ? JSON.stringify(record) : lines[index] as string]
return changed ? JSON.stringify(record) : line
}).join('\n')
}
@@ -748,7 +748,7 @@ describe('normalizeSessionSnapshot', () => {
.split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>) ?? []
expect(delivery?.data).toEqual({ otherVersion: 8 })
expect(delivery?.data).toEqual({ throughSeq: 21, otherVersion: 8 })
expect(captured?.data).toMatchObject({
source: {
references: [
@@ -792,89 +792,6 @@ describe('normalizeSessionSnapshot', () => {
expect(normalized[1]).toBe(lookalike.split('\n')[1])
})
it('removes an inert end-seed and densely remaps declared references for comparison', () => {
const raw = [
{ type: 'session', version: 2, id: 's', createdAt: 0, isSeeded: false, delegationDepth: 0 },
{ type: 'feedback/record', seq: 0, time: 0, data: { text: 'before' } },
{ type: 'session/end-seed', seq: 1, time: 0, data: {} },
{ type: 'session/title', seq: 2, time: 0, data: { title: 'title', messageSeqs: [0, 2] } },
{ type: 'command/done', seq: 3, time: 0, data: { sourceEventSeq: 2 } },
{
type: 'user/message',
seq: 4,
time: 0,
data: {},
sourceEventSeqs: [0, 2],
surfaceOp: { op: 'replace', start: 0, end: 2 },
},
].map(record => JSON.stringify(record)).join('\n')
const records = normalizeSessionFormatProvenance(raw).split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>)
expect(records.map(record => record.type)).not.toContain('session/end-seed')
expect(records[2]).toMatchObject({ seq: 1, data: { messageSeqs: [0, 1] } })
expect(records[3]).toMatchObject({ seq: 2, data: { sourceEventSeq: 1 } })
expect(records[4]).toMatchObject({
seq: 3,
sourceEventSeqs: [0, 1],
surfaceOp: { start: 0, end: 1 },
})
})
it('remaps multiple inert markers, compaction provenance, and opaque reference values', () => {
const raw = [
{ type: 'session', version: 2 },
{ type: 'session/end-seed', seq: 1, data: {} },
{ type: 'session/end-seed', seq: 3, data: {} },
{
type: 'compaction/end',
seq: 5,
data: {
shadowedRange: { start: 0, end: 5 },
shadowedSeqs: 'opaque',
},
},
{
type: 'session-log-deepseek/delivery-accepted',
seq: 6,
data: { retained: true },
},
{
type: 'custom/event',
seq: 7,
sourceEventSeqs: ['opaque', 1.5, 5],
ignorable: true,
},
{ type: 'session/end-seed', data: {} },
].map(record => JSON.stringify(record)).join('\n')
const records = normalizeSessionFormatProvenance(raw).split('\n')
.map(line => JSON.parse(line) as Record<string, unknown>)
expect(records).toHaveLength(4)
expect(records[1]).toMatchObject({
seq: 3,
data: {
shadowedRange: { start: 0, end: 3 },
shadowedSeqs: 'opaque',
},
})
expect(records[2]).toMatchObject({ seq: 4, data: { retained: true } })
expect(records[3]).toMatchObject({ seq: 5, sourceEventSeqs: ['opaque', 1.5, 3] })
})
it('rejects a declared reference to an inert end-seed', () => {
const raw = [
{ type: 'session', version: 2 },
{ type: 'session/end-seed', seq: 1, data: {} },
{ type: 'command/done', seq: 2, data: { sourceEventSeq: 1 } },
].map(record => JSON.stringify(record)).join('\n')
expect(() => normalizeSessionFormatProvenance(raw))
.toThrow('Session snapshot comparison reference targets inert end-seed 1')
})
it('projects persisted provenance ranges back to logical seq arrays', () => {
const raw = [
JSON.stringify({ type: 'session', version: 0 }),
+6
View File
@@ -1688,6 +1688,9 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-session-controller':
specifier: workspace:^
version: link:../../api/session-controller
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../../attachment/attachment
@@ -7182,6 +7185,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-format-v0-to-v1':
specifier: workspace:^
version: link:../session-format-v0-to-v1
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../session-persistence
+32 -1
View File
@@ -164,7 +164,38 @@ def test_snapshot_comparison_expands_embedded_assistant_streams() -> None:
"surfaceOp": "append",
}]
assert normalize(actual) == normalize(expected)
assert normalize(actual, 2) == normalize(expected, 1)
tool_result = {
"type": "tool/result",
"data": {"turn": 1, "step": 1},
"sourceEventSeqs": [4],
}
assert normalize(tool_result, 1)["sourceEventSeqs"] == [4]
assert normalize(tool_result, 2)["sourceEventSeqs"] == [4]
def test_snapshot_stream_expands_reasoning_and_tool_call_records() -> None:
expand = SMOKE["expand_snapshot_stream_member"]
assert expand({
"type": "reasoning-chunks", "time0": 0, "index": 1,
"dt": [], "texts": ["think"],
}) == [{"type": "reasoning-delta", "index": 1, "text": "think"}]
assert expand({
"type": "tool-call-chunks", "time0": 0, "index": 2,
"id": "call-1", "name": "read", "dt": [1], "args": ["{", "}"],
}) == [
{"type": "tool-call-delta", "index": 2, "id": "call-1", "name": "read", "argumentsDelta": "{"},
{"type": "tool-call-delta", "index": 2, "id": "call-1", "name": "read", "argumentsDelta": "}"},
]
def test_snapshot_file_builder_order_is_checked_outside_update_mode(tmp_path: Path) -> None:
compare = SMOKE["compare_snapshot_files"]
with pytest.raises(AssertionError, match="snapshot builder produced"):
compare({}, False, tmp_path, ("result.json",))
def test_snapshot_comparison_expands_sdk_wrapped_attempts() -> None:
+5 -5
View File
@@ -183,7 +183,7 @@ export function parseOptions(argv: readonly string[]): BenchmarkOptions {
return { ...values, smoke, help }
}
/** Calculate the same discrete percentile used by the PR3 acceptance measurement. */
/** Calculate the discrete percentile used by the acceptance measurement. */
export function percentile(values: readonly number[], fraction: number): number {
if (values.length === 0) throw new Error('percentile requires at least one sample')
if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1) {
@@ -210,7 +210,7 @@ async function main(): Promise<void> {
const cases = await materializeCurrentReadCases(root, fixtures)
await validateFixtureReads(cases)
console.log('Session format v2 performance acceptance')
console.log('Session format v2 catalog-dispatch overhead acceptance')
console.log(
'Direct-current/no-dispatch baseline: the released-v2 codec restores the same parsed physical rows. '
+ 'The candidate routes those rows through the static format catalog; public handles validate each file once.',
@@ -226,7 +226,7 @@ async function main(): Promise<void> {
console.log('')
let accepted = true
console.log('Current v2 physical decode and restoration (pooled hot samples)')
console.log('Released-v2 restoration dispatch overhead (pooled hot samples)')
for (const benchmarkCase of cases) {
const result = runPairedCurrentRead(benchmarkCase, options)
accepted &&= result.passed
@@ -263,10 +263,10 @@ async function main(): Promise<void> {
console.log('SMOKE COMPLETE (non-acceptance timing sample)')
} else if (accepted) {
console.log('')
console.log('PASS: every pooled current-read median and p95 regression is within the 5% ceiling.')
console.log('PASS: every pooled catalog-dispatch median and p95 regression is within the 5% ceiling.')
} else {
console.error('')
console.error('FAIL: at least one pooled current-read median or p95 regression exceeds the 5% ceiling.')
console.error('FAIL: at least one pooled catalog-dispatch median or p95 regression exceeds the 5% ceiling.')
process.exitCode = 1
}
} finally {
-1
View File
@@ -95,7 +95,6 @@ describe('client bundle purity gate', () => {
expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-deque')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-timeout')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-util-values')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
+19 -11
View File
@@ -114,7 +114,7 @@ ADVANCED_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
)
ADVANCED_SNAPSHOT_FILENAMES = (
"result.json", "session.v1.jsonl", "session.1.v1.jsonl", "session.2.v1.jsonl",
"result.json", "session.v2.jsonl", "session.1.v2.jsonl", "session.2.v2.jsonl",
)
MINIMAL_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal"
@@ -126,7 +126,7 @@ RESTART_SNAPSHOT_DIRECTORY = (
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart"
)
RESTART_SNAPSHOT_FILENAMES = (
"result.json", "requests.json", "session.1.v1.jsonl", "session.2.v1.jsonl",
"result.json", "requests.json", "session.1.v2.jsonl", "session.2.v2.jsonl",
)
MCP_SERVER_SCRIPT = """\
import json
@@ -1888,7 +1888,7 @@ def expand_snapshot_stream_member(member: object) -> list[dict[str, object]]:
return [chunk]
packed_kinds = {
"text-chunks": ("texts", "text-delta", "text"),
"reasoning-chunks": ("reasoning", "reasoning-delta", "reasoning"),
"reasoning-chunks": ("texts", "reasoning-delta", "text"),
"tool-call-chunks": ("args", "tool-call-delta", "argumentsDelta"),
}
packed = packed_kinds.get(member_type)
@@ -1958,11 +1958,14 @@ def expand_snapshot_assistant_event(value: object) -> list[object]:
return expanded
def normalize_session_format_comparison(value: object) -> object:
def normalize_session_format_comparison(
value: object,
source_session_version: int | None = None,
) -> object:
"""Canonicalize only generation provenance that differs across immutable Session files."""
if isinstance(value, list):
return [
normalize_session_format_comparison(expanded)
normalize_session_format_comparison(expanded, source_session_version)
for item in value
for expanded in expand_snapshot_assistant_event(item)
]
@@ -1970,7 +1973,7 @@ def normalize_session_format_comparison(value: object) -> object:
return value
normalized = {
key: normalize_session_format_comparison(item)
key: normalize_session_format_comparison(item, source_session_version)
for key, item in value.items()
}
if normalized.get("type") == "session" and "version" in normalized:
@@ -1988,6 +1991,7 @@ def normalize_session_format_comparison(value: object) -> object:
if isinstance(normalized.get("type"), str) and "data" in normalized:
normalized.pop("seq", None)
normalized.pop("time", None)
if source_session_version == 1 and normalized.get("type") == "assistant/message":
normalized.pop("sourceEventSeqs", None)
if normalized.get("type") == "session-log-deepseek/delivery-accepted":
data = normalized.get("data")
@@ -2008,11 +2012,15 @@ def normalize_session_format_comparison(value: object) -> object:
def normalize_snapshot_comparison_text(name: str, content: str) -> str:
"""Normalize Session generation provenance only while comparing committed expected outputs."""
if name.startswith("session") and name.endswith(".jsonl"):
parsed = [json.loads(line) for line in content.splitlines() if line]
header = parsed[0] if parsed else None
source_version = header.get("version") if isinstance(header, dict) else None
if not isinstance(source_version, int):
raise AssertionError(f"{name}: snapshot Session header has no integer format version")
records = [
normalize_session_format_comparison(expanded)
for line in content.splitlines()
if line
for expanded in expand_snapshot_assistant_event(json.loads(line))
normalize_session_format_comparison(expanded, source_version)
for record in parsed
for expanded in expand_snapshot_assistant_event(record)
]
return render_jsonl(records)
if name.endswith(".json"):
@@ -2032,7 +2040,7 @@ def compare_snapshot_files(
) -> None:
"""Write or exactly compare one scenario's expected snapshot files."""
scenario = directory.name
if update and tuple(files) != filenames:
if tuple(files) != filenames:
raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}")
if update:
directory.mkdir(parents=True, exist_ok=True)
@@ -1,6 +1,5 @@
{"type":"session","version":0,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","origin":"subagent","delegationDepth":1}
{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
{"type":"session/end-seed","data":{}}
{"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]}}
@@ -9,7 +8,7 @@
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:8}}"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -17,6 +16,6 @@
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:9}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -1,6 +1,5 @@
{"type":"session","version":0,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","origin":"subagent","delegationDepth":1}
{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
{"type":"session/end-seed","data":{}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
@@ -12,7 +11,7 @@
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:14}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:17}}"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[11],"source":{"kind":"fallback"}}}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[10],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -20,7 +19,7 @@
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:18}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":2,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":2}}
@@ -31,6 +30,6 @@
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:19}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -22,7 +22,7 @@
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_foreground"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)\nPartial output before the run ended:\npartial child loader answer"}],"isError":true}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":22}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":17}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_background","name":"subagent_dsh_sdk_background","argumentsDelta":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_background","name":"subagent_dsh_sdk_background","arguments":"{\"description\":\"Observe DSH SDK background failure\",\"prompt\":\"Return the scripted DSH SDK failure.\",\"run_in_background\":true}"}}}}
@@ -33,7 +33,7 @@
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_background"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"{{message:6}}"}},"sourceEventSeqs":[30],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"step/start","data":{"turn":1,"step":3}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":33}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":23}}
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_dsh_sdk_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_dsh_sdk_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}}
@@ -44,7 +44,7 @@
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_dsh_sdk_output"},"content":[{"type":"tool-result","toolCallId":"call_dsh_sdk_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Subagent failure (provider: DSH SDK; stage: session-run; category: child-error)]"}],"isError":false}],"role":"user","id":"{{message:8}}"}},"sourceEventSeqs":[41],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":3}}
{"type":"step/start","data":{"turn":1,"step":4}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":44}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session:1}}","throughSeq":29}}
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}
{"type":"assistant/chunk","data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DSH_SDK_DIAGNOSTIC"}}}}
@@ -1,6 +1,5 @@
{"type":"session","version":0,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","origin":"subagent","delegationDepth":1}
{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
{"type":"session/end-seed","data":{}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
@@ -10,7 +9,7 @@
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[9],"source":{"kind":"fallback"}}}
{"type":"session/title","data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -18,6 +17,6 @@
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -1,6 +1,5 @@
{"type":"session","version":0,"id":"{{session:2}}","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"{{session:1}}","origin":"subagent","delegationDepth":1}
{"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Send a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
{"type":"session/end-seed","data":{}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
@@ -10,7 +9,7 @@
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Send exactly CHILD_MESSAGE_OK to your parent with send_message, then stop."},{"type":"text","text":"Your parent agent id is \"{{session:1}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{session:1}}\", message: \"<self-contained result>\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{message:11}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"Send exactly CHILD_MESSAGE_OK to your","messageSeqs":[9],"source":{"kind":"fallback"}}}
{"type":"session/title","data":{"title":"Send exactly CHILD_MESSAGE_OK to your","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -18,9 +17,9 @@
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:13}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_send_message_1","name":"send_message","arguments":"{\"agent_id\": \"{{session:1}}\", \"message\": \"CHILD_MESSAGE_OK\"}"}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_send_message_1"},"content":[{"type":"tool-result","toolCallId":"call_send_message_1","content":[{"type":"text","text":"message delivered to agent {{session:1}}"}],"isError":false}],"role":"user","id":"{{message:14}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_send_message_1"},"content":[{"type":"tool-result","toolCallId":"call_send_message_1","content":[{"type":"text","text":"message delivered to agent {{session:1}}"}],"isError":false}],"role":"user","id":"{{message:14}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
@@ -28,6 +27,6 @@
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Message sent."}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Message sent."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Message sent."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:15}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
+50 -13
View File
@@ -340,26 +340,19 @@ function stderrFromSession(log: string): string {
open = false
endsWithNewline = true
}
for (const record of records(log)) {
if (record.type === 'turn/start') {
close()
started = true
continue
}
if (!started) continue
const data = record.data as JsonObject | undefined
if (record.type === 'reasoning-chunks') {
const consume = (type: unknown, data: JsonObject | undefined): void => {
if (type === 'reasoning-chunks') {
if (!Array.isArray(data?.texts) || data.texts.some(text => typeof text !== 'string')) {
throw new Error('headless snapshot reasoning chunks have invalid text')
}
for (const text of data.texts as string[]) appendReasoning(text)
continue
return
}
if (record.type === 'text-chunks' || record.type === 'tool-call-chunks') {
if (type === 'text-chunks' || type === 'tool-call-chunks') {
close()
continue
return
}
if (record.type !== 'assistant/chunk') continue
if (type !== 'assistant/chunk' && type !== 'chunk') return
const chunk = data?.chunk as JsonObject | undefined
switch (chunk?.type) {
case 'reasoning-delta':
@@ -383,6 +376,28 @@ function stderrFromSession(log: string): string {
break
}
}
for (const record of records(log)) {
if (record.type === 'turn/start') {
close()
started = true
continue
}
if (!started) continue
const data = record.data as JsonObject | undefined
if ((record.type === 'assistant/message' || record.type === 'assistant/attempt')
&& Array.isArray(data?.stream)) {
for (const entry of data.stream) {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error('headless snapshot embedded stream has an invalid entry')
}
const streamRecord = entry as JsonObject
consume(streamRecord.type, streamRecord)
}
close()
continue
}
consume(record.type, data)
}
close()
const reason = turnReasonFromSession(log)
if (reason?.kind !== 'error') return output
@@ -674,6 +689,28 @@ describe('headless recorded-session snapshots', () => {
].join('\n'))
})
it('reconstructs reasoning stderr from embedded message and attempt streams', () => {
const log = [
{ type: 'turn/start', data: { turn: 1 } },
{
type: 'assistant/attempt',
data: { stream: [{ type: 'reasoning-chunks', texts: ['first', ' thought'] }] },
},
{
type: 'assistant/message',
data: {
stream: [
{ type: 'chunk', chunk: { type: 'reasoning-delta', index: 0, text: 'second' } },
{ type: 'chunk', chunk: { type: 'finish', reason: { kind: 'stop' } } },
],
},
},
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
].map(record => JSON.stringify(record)).join('\n')
expect(stderrFromSession(log)).toBe('dsh: reasoning:\nfirst thought\ndsh: reasoning:\nsecond\n')
})
for (const scenario of scenarios) {
const skipped = scenario.manifest.platform === 'posix' && process.platform === 'win32'
|| scenario.manifest.platform === 'pwsh' && !hasPwsh