diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index ae65d43b61..114c75c579 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-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 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 19650ca6a0..98eb220e49 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index a54a1f30d2..8853e6ed9a 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -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 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml index 1d47bf8885..65328244db 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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 diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md index 80e06b34fb..e50af48485 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md +++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md index 248444c62c..34924a4315 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md @@ -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 固定外部事件表示。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 992e95b8ca..681fb0e2fe 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 2bd56cf807ce7433ead009e5f6df73c6151a6664 -config-catalog.zh.md: ef61011aadd14c22c3a8a0fb3d45b029194a166b +config-catalog.md: 8ff7f5818dd43842b9e934901863c3e9b54656ef +config-catalog.zh.md: 11a8ef58ed16a6bd7616284c292491fb135aa1e1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2bd56cf807..8ff7f5818d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ef61011aad..11a8ef58ed 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index b004400ad7..27bcf349da 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md -deepseek-llm-api-wire-extensions.md: 678661ad1794a76e9e872e041415bb2f0f4db682 -deepseek-llm-api-wire-extensions.zh.md: 83b99d957a20fe3068f862c31faf499f6767acae +deepseek-llm-api-wire-extensions.md: 5b1c4de68949d56f99cbfa70ba4af7ca0b71715d +deepseek-llm-api-wire-extensions.zh.md: a78baace34dcb3e24667295cf06e111ce355aa72 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index 678661ad17..5b1c4de689 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -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 | |---|---|---| diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index 83b99d957a..a78baace34 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -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 也必须升版。 | 成员 | 出现条件 | 含义 | |---|---|---| diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 473e8b12b7..47ea567afd 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 8b271824dddc9d71a3a160ba1bd034c6cff52ab8 -core.zh.md: 0564d2435a35c7c1f6c8c24851dab497ba967586 +core.md: 8050447620bf882da898dc6d2cec9559716a3d5e +core.zh.md: 236a17e31c4f7921126b5cf222f63de14f3bd725 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 8b271824dd..8050447620 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -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' } } ``` diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 0564d2435a..236a17e31c 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -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' } } ``` diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 954097ac0d..363e39f2db 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 0e023521615b00208c90f20a643ec2b87463d42d -README.zh.md: ddbddff545eec58fa23805c9564cb5f87467b5a6 +README.md: ae900465f46186e5fb9fcdd5a0dceb5a419b3cfe +README.zh.md: 6bb3ad7804a43ac2869f684e8d120d3ce8de2303 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 0e02352161..ae900465f4 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -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. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index ddbddff545..6bb3ad7804 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -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 重建会话。 diff --git a/packages/api/session-controller/src/client/contract/events.ts b/packages/api/session-controller/src/client/contract/events.ts index 87ce61b1b4..bc825f08fd 100644 --- a/packages/api/session-controller/src/client/contract/events.ts +++ b/packages/api/session-controller/src/client/contract/events.ts @@ -26,6 +26,11 @@ export type SessionEventLikeEntry = /** Scalar live entry accepted by append-only Client paths. */ export type SessionLiveEventEntry = Extract +/** 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 @@ -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( diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 4bad2bb8c2..c7055f1a93 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -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, diff --git a/packages/api/session-controller/src/client/sessions/assistant-stream.ts b/packages/api/session-controller/src/client/sessions/assistant-stream.ts index 77a1dddcbc..fcecd645d5 100644 --- a/packages/api/session-controller/src/client/sessions/assistant-stream.ts +++ b/packages/api/session-controller/src/client/sessions/assistant-stream.ts @@ -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() + private readonly pending = new Map() private publishedSeqs = new Set() 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 +} diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 4f81209d4c..ecc0bdbf67 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -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') { diff --git a/packages/api/session-controller/tests/assistant-stream.client.spec.ts b/packages/api/session-controller/tests/assistant-stream.client.spec.ts index 34cc63a07f..1b66ca2309 100644 --- a/packages/api/session-controller/tests/assistant-stream.client.spec.ts +++ b/packages/api/session-controller/tests/assistant-stream.client.spec.ts @@ -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() diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index 689d5a407b..ab73b2c11e 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -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() @@ -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', + }) + }) + }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 79acccd49e..73687ae856 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -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, }) }) diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d70fe8cbba..db3b62aa1e 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -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:^", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 09971bb374..c08cdac121 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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 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 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', diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index c9e1eb54ce..8a25ce7598 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -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 { + 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().toEqualTypeOf() + }) + 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] : [] )) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index bf92eabbda..d6d8bad550 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|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 diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 2bfdfd5e96..f401455a32 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -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' + ? '' + : 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) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 54c5167b50..0471a9e271 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 3292c39477a051ac406f453e781376164333ee78 -README.zh.md: 433b2e6c8b3580dadf5dd9e38eaea7b1ab7022a6 +README.md: dabe5afda6554a62f5ca038952e5a02e5a2f712e +README.zh.md: c38ea211c5d8d0ea384cfa39eba2608571166c89 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3292c39477..dabe5afda6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 433b2e6c8b..c38ea211c5 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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。 diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index a07c843037..5259d1af0f 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -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() + 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() + 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. diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 1d77942d1e..a0bab0cb19 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -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 } } diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index eb376eb09d..f7bac8eb1c 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts @@ -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() current.add(seq) diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 368038ef98..15ad36aec2 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -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 = { + 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(( diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 87a671963b..2d3a003756 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -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) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index e4358f91aa..f0958d072d 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -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 }, diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index cd66a41f65..bee111f642 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -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', diff --git a/packages/llm/llm/src/assistant-stream.ts b/packages/llm/llm/src/assistant-stream.ts index 52408a1e0f..74ba2e831a 100644 --- a/packages/llm/llm/src/assistant-stream.ts +++ b/packages/llm/llm/src/assistant-stream.ts @@ -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 } /** diff --git a/packages/llm/llm/tests/assistant-stream.spec.ts b/packages/llm/llm/tests/assistant-stream.spec.ts index 60fa2906f8..824d883873 100644 --- a/packages/llm/llm/tests/assistant-stream.spec.ts +++ b/packages/llm/llm/tests/assistant-stream.spec.ts @@ -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([ diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts index 9c7c2bb768..f990b011cb 100644 --- a/packages/session/session-format-catalog/tests/catalog.spec.ts +++ b/packages/session/session-format-catalog/tests/catalog.spec.ts @@ -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, diff --git a/packages/session/session-format-v0-to-v1/src/dispositions.ts b/packages/session/session-format-v0-to-v1/src/dispositions.ts index 77e39bd2a8..84e980a05d 100644 --- a/packages/session/session-format-v0-to-v1/src/dispositions.ts +++ b/packages/session/session-format-v0-to-v1/src/dispositions.ts @@ -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( diff --git a/packages/session/session-format-v0-to-v1/src/relationships.ts b/packages/session/session-format-v0-to-v1/src/relationships.ts index ce21604a16..ba93e490ba 100644 --- a/packages/session/session-format-v0-to-v1/src/relationships.ts +++ b/packages/session/session-format-v0-to-v1/src/relationships.ts @@ -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 +} + /** * 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() 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': { diff --git a/packages/session/session-format-v1-to-v2/README.i18n.yaml b/packages/session/session-format-v1-to-v2/README.i18n.yaml index ed8eaccfd4..ba08d4ecb3 100644 --- a/packages/session/session-format-v1-to-v2/README.i18n.yaml +++ b/packages/session/session-format-v1-to-v2/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v1-to-v2/README.md -README.md: cff1ebe186e2211247501051da2020a2af673cb9 -README.zh.md: 0acb26502c211c88d72ca2579430ec1641a3ac5f +README.md: 5ead8532b2cce52a0cbf4c39bfe9e54ef14002f1 +README.zh.md: 1f41d24007f95f799a9667910acb93d494a2aa2e diff --git a/packages/session/session-format-v1-to-v2/README.md b/packages/session/session-format-v1-to-v2/README.md index cff1ebe186..5ead8532b2 100644 --- a/packages/session/session-format-v1-to-v2/README.md +++ b/packages/session/session-format-v1-to-v2/README.md @@ -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 | diff --git a/packages/session/session-format-v1-to-v2/README.zh.md b/packages/session/session-format-v1-to-v2/README.zh.md index 0acb26502c..1f41d24007 100644 --- a/packages/session/session-format-v1-to-v2/README.zh.md +++ b/packages/session/session-format-v1-to-v2/README.zh.md @@ -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 | diff --git a/packages/session/session-format-v1-to-v2/src/codec.ts b/packages/session/session-format-v1-to-v2/src/codec.ts index f4150bd5ff..c98a34c65d 100644 --- a/packages/session/session-format-v1-to-v2/src/codec.ts +++ b/packages/session/session-format-v1-to-v2/src/codec.ts @@ -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', diff --git a/packages/session/session-format-v1-to-v2/src/migration.ts b/packages/session/session-format-v1-to-v2/src/migration.ts index 9e94550de6..c0e0f651a1 100644 --- a/packages/session/session-format-v1-to-v2/src/migration.ts +++ b/packages/session/session-format-v1-to-v2/src/migration.ts @@ -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 } diff --git a/packages/session/session-format-v1-to-v2/src/validation.ts b/packages/session/session-format-v1-to-v2/src/validation.ts index 8579164b48..c2380784dd 100644 --- a/packages/session/session-format-v1-to-v2/src/validation.ts +++ b/packages/session/session-format-v1-to-v2/src/validation.ts @@ -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, - allowIgnorableUnknown: boolean, + mode: 'target' | 'current' | 'physical', + knownEventTypes?: ReadonlySet, ): 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, ): SessionFormatArtifact { - validateReleasedV2Artifact(artifact, knownEventTypes, true) + validateReleasedV2Artifact(artifact, 'current', knownEventTypes) return artifact } diff --git a/packages/session/session-format-v1-to-v2/tests/codec.spec.ts b/packages/session/session-format-v1-to-v2/tests/codec.spec.ts index 5bf6e6e49f..97e8647efa 100644 --- a/packages/session/session-format-v1-to-v2/tests/codec.spec.ts +++ b/packages/session/session-format-v1-to-v2/tests/codec.spec.ts @@ -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) diff --git a/packages/session/session-format-v1-to-v2/tests/validation.spec.ts b/packages/session/session-format-v1-to-v2/tests/validation.spec.ts index 0878727963..fed915d1d1 100644 --- a/packages/session/session-format-v1-to-v2/tests/validation.spec.ts +++ b/packages/session/session-format-v1-to-v2/tests/validation.spec.ts @@ -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), diff --git a/packages/session/session-log-deepseek/README.i18n.yaml b/packages/session/session-log-deepseek/README.i18n.yaml index 5927c70135..14bd0b3ede 100644 --- a/packages/session/session-log-deepseek/README.i18n.yaml +++ b/packages/session/session-log-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-log-deepseek/README.md -README.md: eea3cc5abe2b02d20a5aba9ad9b33dbc2a290196 -README.zh.md: 96b5256a050db26f5cf90b927ef9f847a13cbf14 +README.md: 29e6b7860b2654f4ff272c2e9d23d3490527e396 +README.zh.md: ec104bf51c7d9e4589fa19f3fbba2ecc762b1889 diff --git a/packages/session/session-log-deepseek/README.md b/packages/session/session-log-deepseek/README.md index eea3cc5abe..29e6b7860b 100644 --- a/packages/session/session-log-deepseek/README.md +++ b/packages/session/session-log-deepseek/README.md @@ -34,7 +34,7 @@ Shipped profiles mount the plugin so an overlay can enable it, but the default c ## 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. ## Acceptance and retry diff --git a/packages/session/session-log-deepseek/README.zh.md b/packages/session/session-log-deepseek/README.zh.md index 96b5256a05..ec104bf51c 100644 --- a/packages/session/session-log-deepseek/README.zh.md +++ b/packages/session/session-log-deepseek/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" ## 请求字段 -对于携带存活 `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 会话会忽略从父会话继承的水位。 ## 接受与重试 diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index 84e2f157b4..5c771d587b 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -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), diff --git a/packages/session/session-log-deepseek/src/types.ts b/packages/session/session-log-deepseek/src/types.ts index d022098e16..37300e0f35 100644 --- a/packages/session/session-log-deepseek/src/types.ts +++ b/packages/session/session-log-deepseek/src/types.ts @@ -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 diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index 800d42d067..4ab9782c39 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -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, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index b6cd9ccef0..30b82fd12d 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -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:^" } diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 7d0eed3d9b..791414f17b 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -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 { } } +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 { 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) diff --git a/packages/test-support/session-snapshot/src/normalize.ts b/packages/test-support/session-snapshot/src/normalize.ts index 8b76e63df8..b5248e9cf4 100644 --- a/packages/test-support/session-snapshot/src/normalize.ts +++ b/packages/test-support/session-snapshot/src/normalize.ts @@ -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) - 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).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).inherited !== true) return [] + return rawLog.split('\n').map((line) => { + if (line.trim().length === 0) return line + const record = JSON.parse(line) as Record 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 - 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 - 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 - 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 } - 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') } diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index 9d9e0164ff..480f539700 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -748,7 +748,7 @@ describe('normalizeSessionSnapshot', () => { .split('\n') .map(line => JSON.parse(line) as Record) ?? [] - 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) - - 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) - - 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 }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36f2e0ebe7..4e22dcd392 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py index f9b5469687..8dfafd12c8 100644 --- a/python/sdk/tests/test_smoke_model.py +++ b/python/sdk/tests/test_smoke_model.py @@ -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: diff --git a/scripts/benchmark-session-format-v1-to-v2.ts b/scripts/benchmark-session-format-v1-to-v2.ts index d8714364a1..95e5e000e4 100644 --- a/scripts/benchmark-session-format-v1-to-v2.ts +++ b/scripts/benchmark-session-format-v1-to-v2.ts @@ -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 { 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 { 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 { 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 { diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index e7df33c58f..d9b0626670 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -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/) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index ab9cc796fc..77059d9510 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -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) diff --git a/snapshots/sdk/subagent-continuable-inheritance/session.1.jsonl b/snapshots/sdk/subagent-continuable-inheritance/session.1.jsonl index 2c4efd2eed..85987f1181 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/session.1.jsonl +++ b/snapshots/sdk/subagent-continuable-inheritance/session.1.jsonl @@ -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: \"\" }). 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: \"\" }). 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"}}} diff --git a/snapshots/sdk/subagent-continuable/session.1.jsonl b/snapshots/sdk/subagent-continuable/session.1.jsonl index c23187af61..1e0566f988 100644 --- a/snapshots/sdk/subagent-continuable/session.1.jsonl +++ b/snapshots/sdk/subagent-continuable/session.1.jsonl @@ -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: \"\" }). 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"}}} diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.jsonl b/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.jsonl index 463620c1d7..eed5895b3f 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/session.jsonl @@ -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"}}}} diff --git a/snapshots/sdk/subagent-list-agents/session.1.jsonl b/snapshots/sdk/subagent-list-agents/session.1.jsonl index 2373c1419c..7f4a2c7765 100644 --- a/snapshots/sdk/subagent-list-agents/session.1.jsonl +++ b/snapshots/sdk/subagent-list-agents/session.1.jsonl @@ -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: \"\" }). 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"}}} diff --git a/snapshots/sdk/subagent-send-message/session.1.jsonl b/snapshots/sdk/subagent-send-message/session.1.jsonl index ab3a696d93..6c471d0230 100644 --- a/snapshots/sdk/subagent-send-message/session.1.jsonl +++ b/snapshots/sdk/subagent-send-message/session.1.jsonl @@ -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: \"\" }). 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"}}} diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 0e9a8a3f7d..7b2892b97e 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -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