mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(history): adapt packed pages to session journal
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-packed-session-history-transport.md
|
||||
2026-08-15-packed-session-history-transport.md: 64835e4588a2b01d2afc135dd14a95a6f6e8009d
|
||||
2026-08-15-packed-session-history-transport.zh.md: 47b6544fbb05a123c93a274449f030a06c1aa57a
|
||||
2026-08-15-packed-session-history-transport.md: 155dedd8846894119fd62e0cc3aa28dcc0aaa466
|
||||
2026-08-15-packed-session-history-transport.zh.md: 6069e66537e1b22144f35983ea1e3f85e07eaaa9
|
||||
|
||||
+7
-7
@@ -6,19 +6,19 @@ English | [中文](2026-08-15-packed-session-history-transport.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`session.history` and `subagent.history` serve a bounded logical Session-event interval to remote clients. Provider streams can place hundreds of thousands of token-sized `assistant/chunk` events in one incomplete tail. Expanding every persisted row and then serializing every logical event repeats the same envelope on the wire and makes browser parsing and validation process that repetition before conversation replay can begin.
|
||||
`session.page` serves a bounded logical Session-event interval to remote clients. Provider streams can place hundreds of thousands of token-sized `assistant/chunk` events in one incomplete tail. Expanding every persisted row and then serializing every logical event repeats the same envelope on the wire and makes browser parsing and validation process that repetition before conversation replay can begin.
|
||||
|
||||
The transport must remain lossless. Session sequence numbers are pagination and reconnect evidence; exact token boundaries remain useful to diagnostics and non-UI API consumers; live streaming, durable export, replay, and model-history derivation continue to require the canonical event stream. A server-side transcript projection that discards completed-step chunks would make the API's evidence depend on one UI policy.
|
||||
|
||||
## Decision
|
||||
|
||||
History methods return `records: HistoryRecord[]` plus inclusive `fromSeq` and exclusive `toSeq` watermarks. An ordinary record carries `{event, view?}`. Consecutive same-block Assistant delta events carry `{chunks: ChunkRow}` using the shared lossless codec from [the packed JSONL decision](2026-07-26-packed-chunk-rows-by-default.md). The page is selected from logical events before packing, so message-aligned pagination remains independent of physical persistence layout.
|
||||
`session.page` returns `records: SessionHistoryRecord[]`. An ordinary record carries `{event}`. Consecutive same-block Assistant delta events carry `{chunks: ChunkRow}` using the shared lossless codec from [the packed JSONL decision](2026-07-26-packed-chunk-rows-by-default.md). The page is selected from logical events before packing, so message-aligned pagination remains independent of physical persistence layout.
|
||||
|
||||
The wire schema validates every row, rejects unsafe reconstruction, and requires the records to cover `[fromSeq, toSeq)` exactly without gaps or overlaps. The watermarks, not the number of transport records, own older-page stitching, reconnect repair, and live-event deduplication. `session.history` and `subagent.history` share the same response schema.
|
||||
The generated Remote decoder validates the response fields, and the shared row decoder rejects malformed rows and unsafe sequence or timestamp reconstruction. `SessionEventStream` expands the records before passing them to `RemoteJournalStream`; the journal therefore checks page continuity, pagination joins, reconnect repair, and live-event deduplication against the original event sequence numbers. The durable address in the page request selects either an ordinary Session or an authorized direct subagent child without a second history protocol.
|
||||
|
||||
The browser calls the shared `decodeStorageRecord()` codec before handing history to `ConversationNodeAssembler`. Every packed member becomes its exact original `assistant/chunk` event, including `seq`, timestamp, chunk type, block index, text or argument fragment, call identity, and optional-name presence. A registered `ConversationNodeDefinition` therefore receives one `match()` call per historical delta and folds accepted matches with the same start/update sequence it observes for live events. Packing changes transport encoding without changing the public Definition replay semantics.
|
||||
The Client adapter calls the shared `decodeStorageRecord()` codec before publishing a page to the Session object layer. Every packed member becomes its exact original `assistant/chunk` event, including `seq`, timestamp, chunk type, block index, text or argument fragment, call identity, and optional-name presence. A registered `ConversationNodeDefinition` therefore receives one `match()` call per historical delta and folds accepted matches with the same start/update sequence it observes for live events. Packing changes transport encoding without changing the public Definition replay semantics.
|
||||
|
||||
Live `session/event` frames remain individual events. Session persistence, raw export, replay, model-history derivation, and the canonical in-memory log are unchanged.
|
||||
Live `session.follow` frames remain individual events. Session persistence, raw export, replay, model-history derivation, and the canonical in-memory log are unchanged.
|
||||
|
||||
## Measured result
|
||||
|
||||
@@ -32,7 +32,7 @@ A production-sized private session sample was measured without retaining or comm
|
||||
|
||||
Packing reduced uncompressed JSON by 90.8% relative to raw logical events and by 83.4% relative to the lossy completed-step projection candidate. Brotli output was 73.2% smaller than raw and 44.8% smaller than that projection candidate. These figures describe this sample rather than a protocol guarantee; savings scale with the length and regularity of delta runs.
|
||||
|
||||
The opt-in `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, uncompressed chunked Node loopback transfer medians, combined synthetic API-wait/UI-ready timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`; a second inventory reports the median of five exact decodes for 10,000-, 20,000-, and 40,000-member whitespace-prefix runs under `HISTORY_WHITESPACE_PREFIX_PERF_RESULT`. The combined timing starts from an in-memory event array and omits cold persistence reads, projection and presenter work, the production API bridge and RPC envelope, and Chromium scheduling, so it is comparative inventory rather than production wall-clock latency. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/decoding/fold stage, relative to the same initialized benchmark state; they do not measure process RSS, external or ArrayBuffer memory, or transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, exact decoded event count, and identical final state—including delta count and last-delta sequence—from its two-consumer Assistant fold fixture.
|
||||
The opt-in `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark constructs the same logical-event, ordinary-event, and delta-run cardinalities from synthetic content. `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` reports wire sizes, Host/client timing, uncompressed chunked Node loopback transfer medians, combined synthetic API-wait/UI-ready timing, and sampled additional V8 heap peaks under `HISTORY_TRANSPORT_PERF_RESULT`; a second inventory reports the median of five exact decodes for 10,000-, 20,000-, and 40,000-member runs under `HISTORY_WHITESPACE_PREFIX_PERF_RESULT`. The combined timing starts from an in-memory event array and omits cold persistence reads, projection work, the production API bridge and RPC envelope, and Chromium scheduling, so it is comparative inventory rather than production wall-clock latency. Heap measurements force garbage collection before three runs and report the median peak observed after each major Host construction/serialization or Client parse/validation/decoding/fold stage, relative to the same initialized benchmark state; they do not measure process RSS, external or ArrayBuffer memory, or transients within a sampled stage. The manual performance inventory does not run in CI and carries no machine-dependent timing or memory assertions; structural assertions pin the fixture cardinalities, exact decoded event count, and identical final state—including delta count and last-delta sequence—from its two-consumer Assistant fold fixture.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -48,7 +48,7 @@ The opt-in `packages/client/runtime/tests/history-transport.perf.client.ts` benc
|
||||
|
||||
## Consequences
|
||||
|
||||
History responses preserve every logical event while reducing wire bytes, Host response serialization and heap, and browser JSON parsing and validation for long delta runs. Pagination and reconnect logic use explicit raw interval watermarks, so packed transport records do not create false gaps. Existing direct consumers must switch from `events` to the `HistoryRecord` union and decode packed rows before event-level processing.
|
||||
History responses preserve every logical event while reducing wire bytes, Host response serialization and heap, and browser JSON parsing and validation for long delta runs. The journal validates continuity after exact decoding, so packed transport records do not create false gaps. `SessionEventStream` consumers continue to receive ordinary event entries; direct `session.page` consumers must read the `SessionHistoryRecord` union and decode packed rows before event-level processing.
|
||||
|
||||
Cold persisted history is still decoded into the complete logical `SessionEvent[]` before the Host selects and repacks a page. This decision therefore improves transport and browser work, not the Host's cold-read decode memory. Eliminating that expansion requires a persistence-neutral message-boundary index or a separate streaming page reader and remains a distinct optimization.
|
||||
|
||||
|
||||
+7
-7
@@ -6,19 +6,19 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`session.history` 与 `subagent.history` 会向远程客户端提供一段有界的逻辑会话事件区间。提供方流可能在一个未完成尾部中产生数十万个 token 大小的 `assistant/chunk` 事件。先展开每条持久化行,再序列化每个逻辑事件,会在协议中重复相同信封,并让浏览器在 conversation 回放开始前解析和校验这些重复内容。
|
||||
`session.page` 会向远程客户端提供一段有界的逻辑会话事件区间。提供方流可能在一个未完成尾部中产生数十万个 token 大小的 `assistant/chunk` 事件。先展开每条持久化行,再序列化每个逻辑事件,会在协议中重复相同信封,并让浏览器在 conversation 回放开始前解析和校验这些重复内容。
|
||||
|
||||
传输必须保持无损。会话序号是分页与重连证据;精确 token 边界对诊断和非 UI API 消费方仍然有用;实时流式传输、持久导出、回放与模型历史派生仍然需要规范事件流。如果由服务端 transcript 投影丢弃已完成步骤的分片,API 证据就会取决于一项 UI 策略。
|
||||
|
||||
## 决策
|
||||
|
||||
历史方法返回 `records: HistoryRecord[]`,以及包含端 `fromSeq` 与不包含端 `toSeq` 水位。普通记录携带 `{event, view?}`。连续且属于同一块的 Assistant delta 事件使用[打包 JSONL 决策](2026-07-26-packed-chunk-rows-by-default.zh.md)中的共享无损编解码器,携带 `{chunks: ChunkRow}`。系统先从逻辑事件中选择页面,再执行打包,因此按消息对齐的分页不依赖物理持久化布局。
|
||||
`session.page` 返回 `records: SessionHistoryRecord[]`。普通记录携带 `{event}`。连续且属于同一块的 Assistant delta 事件使用[打包 JSONL 决策](2026-07-26-packed-chunk-rows-by-default.zh.md)中的共享无损编解码器,携带 `{chunks: ChunkRow}`。系统先从逻辑事件中选择页面,再执行打包,因此按消息对齐的分页不依赖物理持久化布局。
|
||||
|
||||
协议 schema 校验每一行,拒绝不安全的重建,并要求记录无间隙、无重叠地精确覆盖 `[fromSeq, toSeq)`。更早页面拼接、重连修复与实时事件去重以水位为准,而不以传输记录数量为准。`session.history` 与 `subagent.history` 共用相同的响应 schema。
|
||||
生成的 Remote decoder 会校验响应字段,共享的行 decoder 会拒绝格式错误的行,以及不安全的序号或时间戳重建。`SessionEventStream` 会先展开记录,再将其交给 `RemoteJournalStream`;因此 journal 会依据原始事件序号检查页面连续性、分页拼接、重连修复和实时事件去重。页面请求中的 durable address 既可选择普通 Session,也可选择已授权的 direct subagent child,无需第二套历史协议。
|
||||
|
||||
浏览器会先调用共享的 `decodeStorageRecord()` 编解码器,再把历史交给 `ConversationNodeAssembler`。每个打包成员都会还原为完全一致的原始 `assistant/chunk` 事件,包括 `seq`、时间戳、chunk 类型、block 索引、文本或参数片段、调用身份,以及可选名称是否存在。因此,已注册的 `ConversationNodeDefinition` 会对每个历史 delta 收到一次 `match()` 调用,并按实时事件所具有的同一 start/update 顺序折叠已接受的 match。打包只改变传输编码,不改变公共 Definition 的回放语义。
|
||||
Client adapter 会先调用共享的 `decodeStorageRecord()` 编解码器,再向 Session 对象层发布页面。每个打包成员都会还原为完全一致的原始 `assistant/chunk` 事件,包括 `seq`、时间戳、chunk 类型、block 索引、文本或参数片段、调用身份,以及可选名称是否存在。因此,已注册的 `ConversationNodeDefinition` 会对每个历史 delta 收到一次 `match()` 调用,并按实时事件所具有的同一 start/update 顺序折叠已接受的 match。打包只改变传输编码,不改变公共 Definition 的回放语义。
|
||||
|
||||
实时 `session/event` 帧仍是单个事件。会话持久化、原始导出、回放、模型历史派生与规范内存日志均不改变。
|
||||
实时 `session.follow` 帧仍是单个事件。会话持久化、原始导出、回放、模型历史派生与规范内存日志均不改变。
|
||||
|
||||
## 测量结果
|
||||
|
||||
@@ -32,7 +32,7 @@ Status: implemented
|
||||
|
||||
与原始逻辑事件相比,打包使未压缩 JSON 减少 90.8%;与有损的已完成步骤投影候选相比减少 83.4%。Brotli 输出相对原始形式减少 73.2%,相对该投影候选减少 44.8%。这些数字描述该样本,并非协议保证;收益随 delta run 的长度与规律性变化。
|
||||
|
||||
可选运行的 `packages/client/runtime/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑事件数、普通事件数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/runtime/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告协议体积、Host/client 计时、未压缩且采用 chunked response 的 Node loopback 传输中位数、组合后的合成 API 等待/UI 就绪时间,以及采样的额外 V8 堆峰值;第二组清单会在 `HISTORY_WHITESPACE_PREFIX_PERF_RESULT` 下报告 10,000、20,000 与 40,000 个成员的空白前缀 run 各五次精确解码的中位数。组合计时从内存事件数组开始,不包含冷持久化读取、projection 与 presenter 工作、生产 API bridge 与 RPC 信封,也不包含 Chromium 调度,因此它是对比清单,而非生产环境 wall-clock 延迟。堆测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/解码/折叠各主要阶段之后所观察峰值的中位数;该指标不测量进程 RSS、external 或 ArrayBuffer 内存,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 的事件规模、精确解码事件数,以及双消费方 Assistant 折叠 fixture 的一致最终状态,包括 delta 数量与末个 delta 序号。
|
||||
可选运行的 `packages/client/ui-conversation/tests/history-transport.perf.client.ts` benchmark 使用合成内容构造相同的逻辑事件数、普通事件数与 delta run 数。`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.perf.config.ts packages/client/ui-conversation/tests/history-transport.perf.client.ts` 会在 `HISTORY_TRANSPORT_PERF_RESULT` 下报告协议体积、Host/client 计时、未压缩且采用 chunked response 的 Node loopback 传输中位数、组合后的合成 API 等待/UI 就绪时间,以及采样的额外 V8 堆峰值;第二组清单会在 `HISTORY_WHITESPACE_PREFIX_PERF_RESULT` 下报告 10,000、20,000 与 40,000 个成员 run 各五次精确解码的中位数。组合计时从内存事件数组开始,不包含冷持久化读取、projection 工作、生产 API bridge 与 RPC 信封,也不包含 Chromium 调度,因此它是对比清单,而非生产环境 wall-clock 延迟。堆测量会在三次运行前强制执行垃圾回收,并相对于相同的已初始化 benchmark 状态,报告 Host 构造/序列化或 Client 解析/校验/解码/折叠各主要阶段之后所观察峰值的中位数;该指标不测量进程 RSS、external 或 ArrayBuffer 内存,也可能遗漏单个采样阶段内部的瞬态峰值。CI 不执行这组手动性能用例,其中也没有依赖机器性能的耗时或内存断言;结构断言固定 fixture 的事件规模、精确解码事件数,以及双消费方 Assistant 折叠 fixture 的一致最终状态,包括 delta 数量与末个 delta 序号。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -48,7 +48,7 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
历史响应保留每个逻辑事件,同时减少长 delta run 的协议字节、Host 响应序列化与堆占用,以及浏览器 JSON 解析与校验工作。分页与重连逻辑使用显式原始区间水位,因此打包传输记录不会产生伪间隙。现有直接消费方必须从 `events` 切换到 `HistoryRecord` 联合,并在逐事件处理前解码打包行。
|
||||
历史响应保留每个逻辑事件,同时减少长 delta run 的协议字节、Host 响应序列化与堆占用,以及浏览器 JSON 解析与校验工作。Journal 会在精确展开后校验连续性,因此打包传输记录不会产生伪间隙。`SessionEventStream` 消费方继续收到普通事件条目;直接调用 `session.page` 的消费方必须读取 `SessionHistoryRecord` 联合,并在逐事件处理前解码打包行。
|
||||
|
||||
冷持久历史仍会先解码成完整的逻辑 `SessionEvent[]`,Host 再选择页面并重新打包。因此,本决策改善的是传输与浏览器工作,不是 Host 冷读取的解码内存。消除该展开需要提供方无关的消息边界索引或单独的流式页面读取器,属于另一项优化。
|
||||
|
||||
|
||||
@@ -67,16 +67,6 @@
|
||||
"tests/**/*.{ts,tsx}"
|
||||
]
|
||||
},
|
||||
"packages/api/session-controller": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.perf.client.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/api/remotes": {
|
||||
"entry": [
|
||||
"tests/**/*.e2e.ts"
|
||||
@@ -101,6 +91,14 @@
|
||||
]
|
||||
},
|
||||
"packages/client/ui-conversation": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.{ts,tsx}",
|
||||
"tests/**/*.perf.client.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"tests/**/*.{ts,tsx}"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
]
|
||||
|
||||
@@ -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: 7631e1623f90f9349eca78bc76d46505d13d2e0e
|
||||
README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06
|
||||
README.md: 24df6e1e367eb76e942087065cf9a29a34e94127
|
||||
README.zh.md: 710912be75195567b557c7dc103cabb7eed1e80f
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state.
|
||||
|
||||
History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
|
||||
History pages carry `records`: ordinary records contain a raw `SessionWireEvent`, while consecutive same-block `assistant/chunk` deltas use the Session package's lossless packed-row encoding. `SessionEventStream` expands packed rows member-for-member before publishing a page to the Client Session object layer, so replay still observes every original event and sequence number. Follow frames remain raw individual events. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
|
||||
|
||||
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。
|
||||
|
||||
历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
|
||||
历史页携带 `records`:普通记录包含原始 `SessionWireEvent`,连续且属于同一 block 的 `assistant/chunk` delta 使用 Session 包的无损打包行编码。`SessionEventStream` 会在向 Client Session 对象层发布页面前逐成员展开打包行,因此回放仍能观察到每个原始事件和序号。Follow frame 继续携带单个原始事件。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type {
|
||||
SessionEventEntry,
|
||||
SessionHistoryRecord,
|
||||
SessionWireEvent,
|
||||
} from '../../types.ts'
|
||||
|
||||
/**
|
||||
@@ -14,5 +15,7 @@ import type {
|
||||
export function historyEntries(records: readonly SessionHistoryRecord[]): SessionEventEntry[] {
|
||||
return records.flatMap(record => 'event' in record
|
||||
? [record]
|
||||
: decodeStorageRecord(record.chunks).map(event => ({ event })))
|
||||
: decodeStorageRecord(record.chunks).map(event => ({
|
||||
event: event as unknown as SessionWireEvent,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
// host emits; only the fields the object layer reads).
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
SessionEventEntry,
|
||||
SessionPage,
|
||||
SessionWireEvent,
|
||||
} from '../src/types.ts'
|
||||
|
||||
/** One text content block (local helper). */
|
||||
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
|
||||
@@ -143,15 +148,12 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
|
||||
}
|
||||
|
||||
/** Wrap raw events in the journal envelope returned by history. */
|
||||
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
|
||||
return events.map(event => ({ event }))
|
||||
export function entries(events: readonly SessionEvent[]): SessionEventEntry[] {
|
||||
return events.map(event => ({ event: event as unknown as SessionWireEvent }))
|
||||
}
|
||||
|
||||
/** Build one view-less history response value. */
|
||||
export function historyValue(events: readonly SessionEvent[], hasMore = false): {
|
||||
records: { event: SessionEvent }[]
|
||||
hasMore: boolean
|
||||
} {
|
||||
export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionPage {
|
||||
return {
|
||||
records: entries(events),
|
||||
hasMore,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionHistoryRecord } from '../src/types.ts'
|
||||
import { historyEntries } from '../src/client/sessions/history-records.ts'
|
||||
|
||||
@@ -23,11 +24,14 @@ describe('historyEntries', () => {
|
||||
|
||||
expect(entries).toHaveLength(5)
|
||||
expect(entries[0]).toBe(ordinary)
|
||||
expect(entries.slice(1).map(entry => ({
|
||||
seq: entry.event.seq,
|
||||
time: entry.event.time,
|
||||
chunk: entry.event.type === 'assistant/chunk' ? entry.event.data.chunk : undefined,
|
||||
}))).toEqual([
|
||||
expect(entries.slice(1).map((entry) => {
|
||||
const event = entry.event as unknown as SessionEvent<'assistant/chunk'>
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
chunk: event.data.chunk,
|
||||
}
|
||||
})).toEqual([
|
||||
{ seq: 1, time: 2, chunk: { type: 'text-delta', index: 0, text: 'a' } },
|
||||
{ seq: 2, time: 3, chunk: { type: 'text-delta', index: 0, text: 'b' } },
|
||||
{ seq: 3, time: 4, chunk: { type: 'text-delta', index: 0, text: 'c' } },
|
||||
@@ -52,14 +56,14 @@ describe('historyEntries', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const events = historyEntries([packed]).map(entry => entry.event)
|
||||
const events = historyEntries([packed])
|
||||
.map(entry => entry.event as unknown as SessionEvent<'assistant/chunk'>)
|
||||
|
||||
expect(events).toMatchObject([
|
||||
{ seq: 20, time: 200, data: { chunk: { argumentsDelta: '' } } },
|
||||
{ seq: 21, time: 202, data: { chunk: { argumentsDelta: '{"x":' } } },
|
||||
{ seq: 22, time: 205, data: { chunk: { argumentsDelta: '1}' } } },
|
||||
])
|
||||
expect(events.every(event => event.type === 'assistant/chunk'
|
||||
&& !Object.hasOwn(event.data.chunk, 'name'))).toBe(true)
|
||||
expect(events.every(event => !Object.hasOwn(event.data.chunk, 'name'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,7 +86,7 @@ async function openFollow(
|
||||
function pageEvents(page: SessionPage): SessionWireEvent[] {
|
||||
return page.records.flatMap(record => 'event' in record
|
||||
? [record.event]
|
||||
: decodeStorageRecord(record.chunks))
|
||||
: decodeStorageRecord(record.chunks).map(event => event as unknown as SessionWireEvent))
|
||||
}
|
||||
|
||||
describe('Session history raw journal', () => {
|
||||
@@ -242,10 +242,10 @@ describe('Session history raw journal', () => {
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
|
||||
const sources = Array.from({ length: 128 }, () => session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index, text: 'x' },
|
||||
chunk: { type: 'text-delta', index: 0, text: 'x' },
|
||||
}).seq)
|
||||
const message = session.append('assistant/message', {
|
||||
turn: 1,
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
+105
-29
@@ -7,21 +7,22 @@ import { brotliCompressSync, gzipSync } from 'node:zlib'
|
||||
import { expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { HistoryEntry, HistoryRecord } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
historyEntrySchema,
|
||||
sessionHistoryValueSchema,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api/sessions.schema'
|
||||
import type {
|
||||
SessionEventEntry,
|
||||
SessionHistoryRecord,
|
||||
SessionWireEvent,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { historyEntries } from '@deepseek-ai/dsh-api-session-controller/src/client/sessions/history-records.ts'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ConversationEventInput,
|
||||
ConversationNodeDefinition,
|
||||
ConversationViewDefinition,
|
||||
ConversationViewNode,
|
||||
} from '../src/client/contract/conversation.ts'
|
||||
import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts'
|
||||
import { historyEntries } from '../src/client/sessions/history-records.ts'
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const LOGICAL_EVENTS = 416_756
|
||||
const DELTA_EVENTS = 416_176
|
||||
@@ -68,21 +69,90 @@ interface FoldSnapshots {
|
||||
}
|
||||
|
||||
interface RawHistoryValue {
|
||||
readonly events: HistoryEntry[]
|
||||
readonly events: SessionEventEntry[]
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
|
||||
interface PackedHistoryValue {
|
||||
readonly records: HistoryRecord[]
|
||||
readonly records: SessionHistoryRecord[]
|
||||
readonly hasMore: boolean
|
||||
readonly fromSeq: number
|
||||
readonly toSeq: number
|
||||
}
|
||||
|
||||
const safeIntegerSchema = z.number().int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER)
|
||||
const sessionWireEventSchema = z.object({
|
||||
type: z.string(),
|
||||
seq: safeIntegerSchema,
|
||||
time: safeIntegerSchema,
|
||||
data: z.json(),
|
||||
ignorable: z.literal(true).optional(),
|
||||
sourceEventSeqs: z.array(safeIntegerSchema).optional(),
|
||||
surfaceOp: z.json().optional(),
|
||||
}).strict()
|
||||
const historyEntrySchema = z.object({ event: sessionWireEventSchema }).strict()
|
||||
const chunkRunBaseSchema = {
|
||||
turn: z.number(),
|
||||
step: z.number(),
|
||||
index: z.number(),
|
||||
dt: z.array(safeIntegerSchema),
|
||||
}
|
||||
const textChunkRowSchema = z.object({
|
||||
type: z.enum(['text-chunks', 'reasoning-chunks']),
|
||||
seq0: safeIntegerSchema.nonnegative(),
|
||||
time0: safeIntegerSchema,
|
||||
data: z.object({
|
||||
...chunkRunBaseSchema,
|
||||
texts: z.array(z.string()).min(1),
|
||||
}).strict(),
|
||||
}).strict()
|
||||
const toolCallChunkRowSchema = z.object({
|
||||
type: z.literal('tool-call-chunks'),
|
||||
seq0: safeIntegerSchema.nonnegative(),
|
||||
time0: safeIntegerSchema,
|
||||
data: z.object({
|
||||
...chunkRunBaseSchema,
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
args: z.array(z.string()).min(1),
|
||||
}).strict(),
|
||||
}).strict()
|
||||
const chunkRowSchema: z.ZodType<ChunkRow> = z.discriminatedUnion('type', [
|
||||
textChunkRowSchema,
|
||||
toolCallChunkRowSchema,
|
||||
]).superRefine((row, context) => {
|
||||
const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts
|
||||
if (row.data.dt.length !== members.length - 1) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'packed chunk dt length must be one less than member count',
|
||||
path: ['data', 'dt'],
|
||||
})
|
||||
}
|
||||
if (members.length - 1 > Number.MAX_SAFE_INTEGER - row.seq0) {
|
||||
context.addIssue({ code: 'custom', message: 'packed chunk seqs must stay safe integers', path: ['seq0'] })
|
||||
}
|
||||
let time = row.time0
|
||||
for (let index = 0; index < row.data.dt.length; index++) {
|
||||
time += row.data.dt[index] as number
|
||||
if (Number.isSafeInteger(time)) continue
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'packed chunk times must stay safe integers',
|
||||
path: ['data', 'dt', index],
|
||||
})
|
||||
break
|
||||
}
|
||||
}) as z.ZodType<ChunkRow>
|
||||
const packedHistoryValueSchema: z.ZodType<PackedHistoryValue> = z.object({
|
||||
records: z.array(z.union([
|
||||
historyEntrySchema,
|
||||
z.object({ chunks: chunkRowSchema }).strict(),
|
||||
])),
|
||||
hasMore: z.boolean(),
|
||||
}) as z.ZodType<PackedHistoryValue>
|
||||
const rawSessionHistoryValueSchema: z.ZodType<RawHistoryValue> = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
}) as unknown as z.ZodType<RawHistoryValue>
|
||||
}) as z.ZodType<RawHistoryValue>
|
||||
|
||||
function timed<T>(run: () => T): Timed<T> {
|
||||
const start = performance.now()
|
||||
@@ -302,8 +372,20 @@ function viewDefinition(target: string): ConversationViewDefinition<Conversation
|
||||
}
|
||||
}
|
||||
|
||||
function conversationInputs(entries: readonly HistoryEntry[]): ConversationEventInput[] {
|
||||
return entries.map(entry => ({ event: entry.event, view: entry.view }))
|
||||
function conversationInputs(entries: readonly SessionEventEntry[]): ConversationEventInput[] {
|
||||
return entries.map(entry => ({ event: entry.event as SessionEvent }))
|
||||
}
|
||||
|
||||
function wireEntry(event: SessionEvent): SessionEventEntry {
|
||||
return { event: event as unknown as SessionWireEvent }
|
||||
}
|
||||
|
||||
function wireEntries(events: readonly SessionEvent[]): SessionEventEntry[] {
|
||||
return events.map(wireEntry)
|
||||
}
|
||||
|
||||
function historyRecord(record: SessionEvent | ChunkRow): SessionHistoryRecord {
|
||||
return isChunkRow(record) ? { chunks: record } : wireEntry(record)
|
||||
}
|
||||
|
||||
function assemble(entries: readonly ConversationEventInput[]): FoldSnapshots {
|
||||
@@ -330,9 +412,9 @@ function digest(value: unknown): string {
|
||||
it('reports packed history transport and exact replay costs', async () => {
|
||||
const fixture = timed(buildEvents)
|
||||
|
||||
assemble(conversationInputs(fixture.value.slice(0, 1_000).map(event => ({ event }))))
|
||||
assemble(conversationInputs(wireEntries(fixture.value.slice(0, 1_000))))
|
||||
const rawHostHeap = sampledPeakHeap((sample) => {
|
||||
const entries = fixture.value.map(event => ({ event }))
|
||||
const entries = wireEntries(fixture.value)
|
||||
sample()
|
||||
const json = JSON.stringify({ events: entries, hasMore: false } satisfies RawHistoryValue)
|
||||
sample()
|
||||
@@ -341,29 +423,23 @@ it('reports packed history transport and exact replay costs', async () => {
|
||||
const packedHostHeap = sampledPeakHeap((sample) => {
|
||||
const packedEvents = packChunkRuns(fixture.value)
|
||||
sample()
|
||||
const records = packedEvents.map((record): HistoryRecord =>
|
||||
isChunkRow(record) ? { chunks: record } : { event: record })
|
||||
const records = packedEvents.map(historyRecord)
|
||||
sample()
|
||||
const json = JSON.stringify({
|
||||
records,
|
||||
hasMore: false,
|
||||
fromSeq: 0,
|
||||
toSeq: fixture.value.length,
|
||||
} satisfies PackedHistoryValue)
|
||||
sample()
|
||||
return Buffer.byteLength(json)
|
||||
})
|
||||
|
||||
const rawEntries = timed(() => fixture.value.map(event => ({ event })))
|
||||
const rawEntries = timed(() => wireEntries(fixture.value))
|
||||
const packed = timed(() => packChunkRuns(fixture.value))
|
||||
const packedRecords = timed(() => packed.value.map((record): HistoryRecord =>
|
||||
isChunkRow(record) ? { chunks: record } : { event: record }))
|
||||
const packedRecords = timed(() => packed.value.map(historyRecord))
|
||||
const rawValue: RawHistoryValue = { events: rawEntries.value, hasMore: false }
|
||||
const packedValue: PackedHistoryValue = {
|
||||
records: packedRecords.value,
|
||||
hasMore: false,
|
||||
fromSeq: 0,
|
||||
toSeq: fixture.value.length,
|
||||
}
|
||||
|
||||
const rawJson = timed(() => JSON.stringify(rawValue))
|
||||
@@ -389,7 +465,7 @@ it('reports packed history transport and exact replay costs', async () => {
|
||||
const packedClientHeap = sampledPeakHeap((sample) => {
|
||||
const wire: unknown = JSON.parse(packedJson.value)
|
||||
sample()
|
||||
const parsed = sessionHistoryValueSchema.parse(wire) as unknown as PackedHistoryValue
|
||||
const parsed = packedHistoryValueSchema.parse(wire)
|
||||
sample()
|
||||
const prepared = conversationInputs(historyEntries(parsed.records))
|
||||
sample()
|
||||
@@ -401,7 +477,7 @@ it('reports packed history transport and exact replay costs', async () => {
|
||||
const parsedRaw = timed((): unknown => JSON.parse(rawJson.value))
|
||||
const parsedPacked = timed((): unknown => JSON.parse(packedJson.value))
|
||||
const rawValidation = timed(() => rawSessionHistoryValueSchema.parse(parsedRaw.value))
|
||||
const packedValidation = timed(() => sessionHistoryValueSchema.parse(parsedPacked.value) as unknown as PackedHistoryValue)
|
||||
const packedValidation = timed(() => packedHistoryValueSchema.parse(parsedPacked.value))
|
||||
const rawPreparation = timed(() => conversationInputs(rawValidation.value.events))
|
||||
const packedPreparation = timed(() => conversationInputs(historyEntries(packedValidation.value.records)))
|
||||
|
||||
@@ -534,7 +610,7 @@ it('reports exact decoding cost for long whitespace-prefix runs', () => {
|
||||
},
|
||||
}])
|
||||
const results = [10_000, 20_000, 40_000].map((members) => {
|
||||
const record: HistoryRecord = {
|
||||
const record: SessionHistoryRecord = {
|
||||
chunks: {
|
||||
type: 'reasoning-chunks',
|
||||
seq0: 0,
|
||||
@@ -4443,6 +4443,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionCancelValue',
|
||||
declaration: 'export interface SessionCancelValue {\n readonly accepted: true;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionChunkRun',
|
||||
declaration: 'export interface SessionChunkRun {\n readonly chunks: ChunkRow;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionControlBaseline',
|
||||
declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>;\n readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>;\n readonly projections: Readonly<Record<SessionId, SessionProjectionBaseline>>;\n}',
|
||||
@@ -4537,7 +4541,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionFollowFrame',
|
||||
declaration: 'export type SessionFollowFrame = {\n readonly type: \'snapshot\';\n readonly header: SessionHeader;\n readonly cursor: number;\n readonly events: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n readonly projections: SessionProjectionBaseline;\n} | ({\n readonly type: \'event\';\n} & SessionEventEntry);',
|
||||
declaration: 'export type SessionFollowFrame = {\n readonly type: \'snapshot\';\n readonly header: SessionHeader;\n readonly cursor: number;\n readonly records: readonly SessionHistoryRecord[];\n readonly hasMore: boolean;\n readonly projections: SessionProjectionBaseline;\n} | ({\n readonly type: \'event\';\n} & SessionEventEntry);',
|
||||
},
|
||||
{
|
||||
name: 'SessionFollowRequest',
|
||||
@@ -4559,6 +4563,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionHeader',
|
||||
declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionHistoryRecord',
|
||||
declaration: 'export type SessionHistoryRecord = SessionEventEntry | SessionChunkRun;',
|
||||
},
|
||||
{
|
||||
name: 'SessionId',
|
||||
declaration: 'export type SessionId = Branded<\'SessionId\'>;',
|
||||
@@ -4605,7 +4613,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionPage',
|
||||
declaration: 'export interface SessionPage {\n readonly events: readonly SessionEventEntry[];\n readonly hasMore: boolean;\n}',
|
||||
declaration: 'export interface SessionPage {\n readonly records: readonly SessionHistoryRecord[];\n readonly hasMore: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionPageRequest',
|
||||
|
||||
Generated
+3
@@ -2161,6 +2161,9 @@ importers:
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
packages/client/ui-deliverables:
|
||||
devDependencies:
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
execArgv: [...vitestExecArgv, '--expose-gc'],
|
||||
include: [
|
||||
'apps/web/tests/**/*.perf.ts',
|
||||
'packages/api/session-controller/tests/**/*.perf.client.ts',
|
||||
'packages/client/ui-conversation/tests/**/*.perf.client.ts',
|
||||
],
|
||||
disableConsoleIntercept: true,
|
||||
hookTimeout: 180_000,
|
||||
|
||||
Reference in New Issue
Block a user