Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

This commit is contained in:
Chinesezjc
2026-08-06 11:25:28 +08:00
3992 changed files with 241706 additions and 57716 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412
README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989
README.md: e2c014a1448125b23475d6cdf52c02f10fc54794
README.zh.md: a4aeead796961bd66a6c7ca1f9a66dffb60eba5d

View File

@@ -14,7 +14,6 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -35,7 +34,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
### Class: `Session`
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
Plain class (not a Cordis Service). Create live sessions through `ctx.sessions.create()` and detached replay or inspection sessions through `Session.create()`; the detached factory does not publish lifecycle events or bind the session to a fiber.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over the complete identified, frozen messages stored by those entries. Assistant messages preserve provider/model provenance and adapter-private replay state in their model source. A surface rewrite rebuilds the projection; there is no raw-log fallback.
@@ -49,35 +48,33 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit.
Session-event import separates ownership from message validation. `snapshotSessionEvent(event)` clones a borrowed event before validating and freezing its identified message. `adoptSessionEvent(event)` performs the same message work in place and returns the original event; callers may use it only when they transfer an exclusively owned object graph with no mutable child shared with another event.
### Chunk-row storage codec (`chunk-rows.ts`)
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the backend's default-enabled `packChunks` config controls writes only.
The shared [storage codec](src/chunk-rows.ts) losslessly converts event sequences to compact rows and back. It preserves unrecognized events verbatim and rejects malformed encoded rows; persistence backends decide whether to enable packed writes.
### Surface types
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
This package owns ordered surface projection, replacement validation, replay, and the type guards that distinguish append-origin from replacement events. The [surface type catalog](../../../docs/core-data-structures/session.md#surface-types) owns the exact shapes and field semantics. A human transcript must project append-origin events rather than `session.surface`, because landed replacements shadow history the reader already saw; model-facing consumers continue to read `session.surface`.
### Request-header reconstruction (`request-header.ts`)
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
A `user/message` stores the complete `UserMessage` directly, including the identity created before inbox routing or step entry. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an entered goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message` and `tool/result` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`; `agent.inject()` queues input until a later pre-step claims it and returns it in an enter decision.
`tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message.
### Session event vocabulary (`types.ts`)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. A plugin owns the relational invariant for its merged events, including whether a log-only event may appear between turns. A producer that requires durability appends through `Session` and then awaits `ctx.sessions.flush(session)` without fabricating an execution turn.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following entered `user/message` batch records its input, while `llm/retry` records request recovery.
An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state.
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
Every `SessionEvent` carries two optional top-level fields (structural metadata):
@@ -91,7 +88,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, and assistant messages require provider/model provenance. Persistence owns read compatibility before constructing this current-format seed. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
## Model Experience
@@ -100,7 +97,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
The model receives the complete messages from `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect

View File

@@ -4,7 +4,7 @@
事件溯源的会话日志和内存存储。`Session` 是 agent(智能体)全部交互历史的仅追加真源,LLM(大语言模型)消息历史由它*派生*。原始日志之上维护一个 **surface** 层(产生消息事件的有序投影),以便高效派生和压缩(compaction)。
可选配套入口 `@deepseek-ai/dsh-session/invariant` 将此包(package)的关系轨迹检查注册到 `ctx.invariants`:序号单调递增、轮次/步骤闭合,以及同一步骤内的工具调用/结果配对。加载或重新加载时,它会回放现有会话;存储校验、快照、冻结、溯源信息和 surface 准入仍始终由根会话包负责。
可选配套入口 `@deepseek-ai/dsh-session/invariant` 将此包的关系轨迹检查注册到 `ctx.invariants`:序号单调递增、轮次/步骤闭合,以及同一步骤内的工具调用/结果配对。加载或重新加载时,它会回放现有会话;存储校验、快照、冻结、溯源信息和 surface 准入仍始终由根会话包负责。
## 服务:`SessionStore`(ctx 键:`sessions`)
@@ -14,7 +14,6 @@
- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。
- `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败。未发布、已脱离和陈旧的对象会被拒绝。
- `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -27,7 +26,7 @@
- `enter(session)` 执行冲突检查,在不通知的情况下发布,并返回一个绑定到该条目的幂等脱离函数。允许并发准备相同 id,但只有一个条目能够成功进入;陈旧的脱离函数无法移除其替代项。
- `announce(session)` 发出唯一一次创建边,并拒绝重复或重入通知。该次分发期间请求的脱离操作会延后,之后再发出成对的释放边;未通知的条目不会发出任何生命周期边。
`dsh-agent-loop` 使用这一拆分,以保证循环的最终刷新先于会话脱离;详见[所有权 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)。
`dsh-agent-loop` 使用这一拆分,以保证循环的最终刷新先于会话脱离;详见[所有权 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md)。
### 实时服务事件
@@ -35,7 +34,7 @@
### 类:`Session`
普通类(不是 Cordis 服务)。通过 `ctx.sessions.create()` 创建。
普通类(不是 Cordis 服务)。活跃会话通过 `ctx.sessions.create()` 创建,脱离态的回放或检查会话通过 `Session.create()` 创建;脱离态工厂不会发布生命周期事件,也不会将会话绑定到 fiber。
- `session.append(type, data, opts?)` 会为持久数据和 surface 元数据制作快照并冻结它们,校验标记形态、溯源信息、替换覆盖完整性,以及仅修改内容的单个 `tool/result` 重写,随后同步提交,再在彼此独立的失败收容下通知观察者。对已附加会话的重入追加会被拒绝,运行时检查也覆盖扩宽后的联合类型和已加载日志。
- `session.deriveMessages()` 对每个新的 surface 条目只做一次增量投影,并返回一个新数组,其中包含这些条目存储的完整、带标识且冻结的消息。assistant 消息会在其模型来源中保留提供方/模型溯源信息及适配器私有回放状态。surface 重写会重建投影;不存在原始日志回退。
@@ -49,35 +48,33 @@
持久值需要一种已接受的表示,不能先检查再二次读取。`isJsonValue(value)` 是布尔判断函数;`snapshotJsonValue(value)` 在一趟迭代中校验并复制普通值,无效输入返回 `undefined`,getter 抛出的异常则向外传播。快照辅助函数接受除 `-0` 外的有限 JSON 数值(JSON 会将其改写为 `0`)、稠密普通数组、普通对象或 null 原型对象;它会在规范化前拒绝循环引用、不支持的标量和特殊原型,同时不施加调用栈深度限制。
会话事件导入将所有权与消息校验分开处理。`snapshotSessionEvent(event)` 会先克隆借用的事件,再校验并冻结其中带标识的消息。`adoptSessionEvent(event)` 原地执行相同的消息处理并返回原事件;调用方只有在移交独占的对象图,且该对象图没有与其他事件共享可变子对象时,才可以使用此函数。
### 分片行存储编解码器(`chunk-rows.ts`)
提供方以 token 大小的增量流式输出,因此原始日志会存储数百行 `assistant/chunk`,其 JSON 封装远大于载荷。`packChunkRuns(events)` 将每段至少 3 个连续、同块的增量分片打包为一个存储行:`text-chunks`、`reasoning-chunks` 或 `tool-call-chunks`(不含斜杠的裸标签,属于存储词汇而不是 `SessionEventMap` 成员)。`decodeStorageRecord(value)` 则将已解析行展开回完全一致的事件(`seq0`/`time0` 加上每个成员的 `dt` 间隔,可重建每个 `seq`/`time`)。编码器只允许精确形态,并逐字存储任何无法识别的内容;解码器校验带行标签的值,形态错误时抛出异常。编解码器由此包所有,使 JSONL 后端和 fixture(测试前置数据)读取器(`dsh-llm-replay`、`dsh-acp-snapshot`)共享同一编解码器;后端默认启用的 `packChunks` 配置只控制写入。
共享的[存储编解码器](src/chunk-rows.ts)在事件序列与紧凑行之间无损转换。它会逐字保留无法识别的事件,并拒绝形态错误的编码行;是否启用打包写入由持久化后端决定。
### Surface 类型
- `SurfaceOp`:事件进入有序 surface 的方式,即 `'append'`(正常尾部追加)或 `{ op: 'replace', start, end }`(替换从 `start` 到 `end` 的条目,含两端;二者都必须是有效的 surface 序号;`start === end` 时替换一个条目)。压缩用它遮蔽旧事件而不删除它们。
- `SurfaceIntent`:`{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`,可进入 surface 的类型调用 `session.append()` 时必需的第三个参数。
- `SessionSurface`:实时只读 `nodes` 和 `replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。
- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。
- `isSurfaceEvent(event)`/`isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。
此包拥有有序 surface 投影、替换校验、回放,以及区分追加来源事件与替换事件的类型守卫。[surface 类型目录](../../../docs/core-data-structures/session.md#surface-types)拥有精确形状与字段语义。面向人的 transcript(文本记录)必须投影追加来源事件,而不是 `session.surface`,因为已落地的替换会遮蔽读者已经看到的历史;面向模型的消费方继续读取 `session.surface`。
### 请求头重建(`request-header.ts`)
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或 pre-step 领取前创建的标识。无论它是直接人类提示词、合成注入,还是进入步骤的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message` 和 `tool/result` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围;`agent.inject()` 会把输入排队,直到某次 pre-step 返回 enter 并在轮次内记录它。
`tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。
### 会话事件词汇(`types.ts`)
生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存;运行错误的步骤记录在 `turn/end.reason` 上(此时为 `kind: 'error'`),最终模型请求失败时还包含结构化的提供方事实。
生成的[持久化日志事件目录](../../../docs/persistence-catalog.md)逐成员列举仅追加日志的事件类型、载荷、surface 标记和溯源信息。Token 记账读取每个步骤的 `assistant/chunk { type: 'usage' }` 记录;如果没有用量分片,则将 `assistant/message.usage` 作为已提交步骤的后备。失败的模型请求尝试没有 assistant 消息。提供方/模型/回放溯源信息随 `assistant/message` 一同保存。
`SessionEventMap` 可通过合并扩展:插件使用声明合并添加自身类型(压缩 seam 的 `compact/*`、有界恢复的非 surface `llm/retry`、hook(钩子)桥接层的 `hook/*`);合并成员会出现在同一目录中。插件拥有其合并事件的关系不变量,包括是否允许纯日志事件出现在轮次之间。需要持久性的生产方通过 `Session` 追加,再等待 `ctx.sessions.flush(session)`,无需虚构一个执行轮次。
此包还定义 `TurnTriggerMap` 和 `TurnEndReasonMap`(用于类型化轮次边界、可合并扩展的和类型;以 `kind` 为标签而不是字符串)。最终模型请求错误保留一个结构化 `LlmFailure`;其他轮次错误保留消息/代码,两者均标识失败步骤。
此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后进入步骤的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。
被中断的实时轮次以粗粒度的 `{ kind: 'aborted' }` 结果结束。调用方身份属于 Agent 的运行时取消信号,不属于持久 transcript(文本记录);资源释放仍是独立的 `{ kind: 'disposed' }` 终态。
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。
每个 `SessionEvent` 都有两个可选顶层字段(结构元数据):
@@ -91,7 +88,7 @@
### 扩展点
- 持久化插件:订阅 `session/event`(延后写入),并在 `session/flush`(受等待)及 fiber dispose(资源释放)时排空。持久后端读取日志并重新加载到实时会话;这类后端会把元数据 seam(`SessionHeader`、`session.header`)与日志一同存储。
- 回放/fork:`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface;请求头必须包含提供方/模型,assistant 消息必须包含提供方/模型溯源信息,而粗粒度中止结果必须只含 `{ kind: 'aborted' }`(带旧版原因的记录会被拒绝)。`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。
- 回放/fork:`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface;请求头必须包含提供方/模型,assistant 消息必须包含提供方/模型溯源信息。持久化层在构造该当前格式 seed 前负责读取兼容性处理。`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。
- 压缩:`dsh-compact-basic` 为摘要检查点追加一个替换用 `user/message`,而 `dsh-compact-tool-result-prune` 追加仅修改内容的 `tool/result` 替换。工具配对边界策略及其缓存归 [`dsh-compact` seam](../../compact/compact/README.md) 所有;此包拥有有序 surface 成员关系、替换校验与 `replaceGeneration`。
## 模型体验
@@ -100,7 +97,7 @@
#### 模型看到的内容
模型会原样接收 `user/message`、`assistant/message`、`tool/result` 和 `steering/message` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
模型会原样接收 `user/message`、`assistant/message` 和 `tool/result` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
#### Token 影响
@@ -138,7 +135,7 @@
记录日志不会导致失效,精确重建会保持请求前缀一致。后续请求头若更改前缀、提示词或 schema,可能从第一处差异开始使复用失效。
## 已知限制与暂缓工作
## 已知限制与暂缓事项
- **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。

View File

@@ -30,9 +30,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -23,30 +23,30 @@ export * from './types.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, ignoring other triggers and
* between-turn events.
* Find the latest closed turn that entered at least one model step, ignoring
* balanced no-step turns produced by rejection, empty input, or cancellation.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
const messageTurns = new Set<number>()
const steppedTurns = new Set<number>()
let latest: SessionEvent<'turn/end'> | undefined
for (const event of events) {
if (event.type === 'turn/start') {
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
if (event.type === 'step/start') {
steppedTurns.add(event.data.turn)
continue
}
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
if (event.type === 'turn/end' && steppedTurns.delete(event.data.turn)) latest = event
}
return latest
}
@@ -93,8 +93,7 @@ declare module 'cordis' {
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/**
* Awaited parallel durability checkpoint: every listener runs and the
* caller awaits all of them, with no waterfall veto. Dispatch through
* {@link SessionStore.flush}. Scope-filtered dispatch
* caller awaits all of them, with no waterfall veto. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
* @param session - the session whose buffered events must reach durable storage.
* @dshScopeScan unsupported
@@ -104,17 +103,12 @@ declare module 'cordis' {
}
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
/** Validate and freeze one detached creation header in place. */
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('session header is not a plain JSON record')
}
const record = snapshot as Record<string, unknown>
const record = input as Record<string, unknown>
if (record.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
}
@@ -139,6 +133,9 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
throw new Error('session header seedLength must be a non-negative safe integer')
}
if (record.origin !== undefined && record.origin !== 'subagent') {
throw new Error('session header origin must be "subagent"')
}
if (record.delegationDepth !== undefined
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
throw new Error('session header delegationDepth must be a non-negative safe integer')
@@ -146,31 +143,51 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
return deepFreeze(record as unknown as SessionHeader)
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
return validateSessionHeader(id, snapshot)
}
/**
* Validate an exclusively owned event and deeply freeze its identified message
* without copying the event. The caller transfers an object graph that no
* producer retains and that shares no mutable children with another event.
* Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
* @param event - exclusively owned event imported across a trusted boundary.
* @returns the same event object with a validated, deeply frozen message.
*/
export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
assertMessageEventShape(
event,
`session event at seq ${event.seq}`,
)
switch (event.type) {
case 'user/message':
deepFreeze(event.data)
break
case 'assistant/message':
case 'tool/result':
deepFreeze(event.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return event
}
/**
* Detach one event while preserving deep immutability for its identified message.
* @param event - event imported across a query or persistence boundary.
* @returns a detached event snapshot with a validated, deeply frozen message.
*/
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
const snapshot = structuredClone(event)
assertMessageEventShape(
snapshot,
`session event at seq ${snapshot.seq}`,
)
switch (snapshot.type) {
case 'user/message':
deepFreeze(snapshot.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(snapshot.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return snapshot
return adoptSessionEvent(structuredClone(event))
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
@@ -190,7 +207,6 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
assertCurrentLlmShape(event, index)
assertCurrentTurnEndShape(event, index)
}
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
@@ -201,25 +217,50 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
: undefined
if (event['type'] === 'request/header') {
const header = record?.['header']
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header)
? header as Record<string, unknown>
: undefined
const config = headerRecord?.['config']
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
const configRecord = config as Record<string, unknown>
const reasoningEffort = configRecord['reasoningEffort']
if (reasoningEffort !== undefined
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
}
assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index)
}
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
&& type !== 'tool/result') return
assertMessageEventShape(event, `seed ${type} at index ${index}`)
}
/** Validate adapter-default provenance imported from a durable request header. */
function assertAdapterDefaults(
value: unknown,
config: Record<string, unknown>,
index: number,
): void {
if (value === undefined) return
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
}
const defaults = value as Record<string, unknown>
const allowed = new Set(['reasoningEffort', 'maxTokens'])
if (Object.keys(defaults).some(key => !allowed.has(key))
|| Object.values(defaults).some(marker => marker !== true)
|| defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
|| defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
}
}
/** Validate only the event-specific invariants needed to safely replay a message. */
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
&& type !== 'tool/result') return
const data = event['data']
const record = typeof data === 'object' && data !== null
? data as Record<string, unknown>
@@ -269,22 +310,6 @@ function assertMessageEventShape(event: Record<string, unknown>, subject: string
}
}
/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */
function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void {
if (event['type'] !== 'turn/end') return
const data = event['data']
/* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */
if (typeof data !== 'object' || data === null) return
const reason = (data as Record<string, unknown>)['reason']
/* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */
if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return
const record = reason as Record<string, unknown>
if (record['kind'] === 'aborted'
&& (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) {
throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`)
}
}
/** Whether an unknown value carries the current provider/model pair. */
function hasProviderModel(value: unknown): boolean {
if (typeof value !== 'object' || value === null) return false
@@ -351,8 +376,10 @@ const attachments = new WeakMap<Session, SessionEntry>()
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
* Plain class (not a Service) — create instances via `ctx.sessions.create()`.
* Plain class (not a Service) — create live instances via
* `ctx.sessions.create()` and detached instances via {@link create}.
* Seeding with an existing event log replays/forks a session.
* @typert object
*/
export class Session {
private log: SessionEvent[] = []
@@ -367,7 +394,7 @@ export class Session {
/**
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* `Session` is created without a store-owned header, a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state.
@@ -381,19 +408,42 @@ export class Session {
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact
* and is deliberately not persisted.
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit), so consumers
* that replay the log as a publication substitute (telemetry adoption)
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed session's constructor seed is its full stored log,
* while its header keeps the original fork value — this field is the
* in-process construction fact.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
*/
readonly firstLiveSeq: number
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
* @param id - session identity.
* @param seed - optional borrowed replay or fork events.
* @param header - optional borrowed storage metadata.
* @returns a detached session.
*/
static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session {
return new Session(id, seed, header)
}
private constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed !== undefined) {
// Validate the seed to the SAME invariants `append` enforces, so a
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
// live log that no persistence backend could store: each event's `data`
@@ -426,6 +476,13 @@ export class Session {
}
this.firstLiveSeq = this.log.length
this.header = snapshotSessionHeader(id, header)
// Appended here so the marker is already in `events` when a backend
// captures the creation seed: no load-time write. Re-marking is skipped
// because a cold session is resumed on first touch, so repeatedly opening
// one must not grow its log per open.
if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}
/** Cached immutable public snapshot of the private append-only log. */
@@ -461,7 +518,8 @@ export class Session {
* the ordered surface; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
* declare how it joins the surface, the sole source of derived history) and
* declare how it joins the surface, the sole source of derived model
* history) and
* rejected by the compiler for non-surface types like `turn/start` or
* `assistant/chunk`.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
@@ -559,6 +617,25 @@ export class Session {
return this.headerFold
}
/** Cached fold of `request/context` events. */
private contextFold: RequestContext | undefined
private contextFoldSeq = 0
/**
* Return the latest resolved route metadata, or `undefined` before the first
* `request/context` event. Each event is folded once.
* @returns the latest immutable route metadata.
*/
requestContext(): RequestContext | undefined {
if (this.contextFoldSeq < this.log.length) {
for (const event of this.log.slice(this.contextFoldSeq)) {
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
}
this.contextFoldSeq = this.log.length
}
return this.contextFold
}
/** The derived-message cache: frozen projections, extended per unseen node. */
private derived: Message[] = []
/** Surface position (nodes projected) the cache has reached. */
@@ -596,7 +673,7 @@ export class Session {
for (const seq of nodes.slice(this.derivedNodes)) {
// Surface sequences are built from this.log — seq is always a valid
// index by construction. The non-null assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const msg = this.deriveEventMessage(this.log[seq]!)
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
@@ -626,10 +703,9 @@ export class Session {
// trace/replay data.
switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. Steering's `turn` is log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// Ordinary prompts and injected context project in user role: the
// event's model-facing content stays verbatim. Do NOT
// re-add per-type framing (e.g. `<context>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by
// the event `meta` map and a dedicated renderer, keeping this projection a
@@ -638,9 +714,6 @@ export class Session {
case 'user/message': {
return event.data
}
case 'steering/message': {
return event.data.message
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
@@ -708,7 +781,7 @@ export class SessionStore extends Service {
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before the store attachment ends), do NOT use this
* loop's final events are published before the store attachment ends), do NOT use this
* — fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see
* `dsh-agent-loop`'s creation transaction).
@@ -740,7 +813,7 @@ export class SessionStore extends Service {
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects — which would remove the publication hooks
* before the loop's closing `session/flush`, dropping the closing events.
* before the driver's closing events commit, dropping them.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
@@ -767,9 +840,10 @@ export class SessionStore extends Service {
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
...meta?.origin === undefined ? {} : { origin: meta.origin },
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
}
return new Session(sessionId, seed, header)
return Session.create(sessionId, seed, header)
}
/**
@@ -893,15 +967,17 @@ export class SessionStore extends Service {
/**
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
* with the carrier captured at {@link enter}. THE flush entry point: the
* store owns the carrier, so callers (the loop's turn-end checkpoint, idle
* injection, teardown drains) must come through here rather than dispatch a
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
* scoped-dispatch invariant can pin it.
* store owns the carrier, so callers (the checkpoint policy's per-request
* barrier, goal-session's idle checkpoint, teardown drains, and consumers
* that flush themselves before reading storage) must come through here
* rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner,
* one spelling, and the scoped-dispatch invariant can pin it.
* @param session - the session whose buffered events must reach durable storage.
* @returns resolves when every flush listener has settled; after all settle,
* rejects with the first registered listener failure if any listener failed.
* @returns whether at least one durability listener participated, after every
* listener has settled successfully.
* @throws the first registered listener failure after every listener settles.
*/
async flush(session: Session): Promise<void> {
async flush(session: Session): Promise<boolean> {
const { carrier } = this.liveEntryFor(session)
const callbackArgs: unknown[] = [session]
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
@@ -911,12 +987,13 @@ export class SessionStore extends Service {
} catch (error: unknown) {
// Preserve the listener's exact rejection value; flush is a caller-owned
// failure boundary, and Cordis listeners may throw arbitrary values.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
return Promise.reject(error)
}
}))
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
if (failure !== undefined) throw failure.reason
return callbacks.length > 0
}
/** Return the exact live entry; detached/prepared objects reject. */

View File

@@ -144,9 +144,12 @@ function validateEvent(
}
case 'user/message':
break
case 'steering/message':
case 'session/end-seed':
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
break
case 'todo/write':
case 'request/header': {
case 'request/header':
case 'request/context': {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
}

View File

@@ -1,7 +1,10 @@
/**
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript.
* needed to resume with a provider-valid transcript, plus the activity-time
* read that must skip the end-seed boundary — which this module does
* not write (`Session`'s constructor does) but whose synthetic closers can
* inherit that boundary's timestamp, the one real coupling between the two.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -9,6 +12,22 @@ import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/**
* The `time` of the log's last event representing actual work, skipping the
* `session/end-seed` boundary — picking a session up is not activity, so
* activity ordering must exclude it.
*
* Excluded by type, so a pickup time still leaks when a boundary is the last
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
* unbalanced log directly — `load()` balances first.
* @param events - the log to scan, in seq order.
* @returns the latest non-boundary event's `time`, or undefined when there is none.
*/
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
return events.findLast(event => event.type !== 'session/end-seed')?.time
}
/** Recovery code for an assistant tool request that never reached a recorded call start. */
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'

View File

@@ -19,8 +19,12 @@ import type { EpochHeader, SessionEvent } from './types.ts'
* @returns the canonical header.
*/
export function canonicalHeader(header: EpochHeader): EpochHeader {
const adapterDefaults = header.adapterDefaults
return {
config: header.config,
...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true
? { adapterDefaults }
: {},
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
}
@@ -38,7 +42,12 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
* @returns whether config, system, and tools all match.
*/
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
if (
!callConfigEquals(a.config, b.config)
|| a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort
|| a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens
|| a.system !== b.system
) return false
const at = a.tools ?? []
const bt = b.tools ?? []
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))

View File

@@ -15,13 +15,12 @@ const SURFACE_EVENT_TYPES = new Set<string>([
'user/message',
'assistant/message',
'tool/result',
'steering/message',
])
/**
* Whether an event type can join the model-visible surface.
* @param type - event type to test.
* @returns true for one of the four message-producing event types.
* @returns true for one of the three message-producing event types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
@@ -37,6 +36,36 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
return (event as SessionEvent<SurfaceEventType>).surfaceOp !== undefined
}
/**
* Narrow an event to an append-origin surface event: one that entered the
* surface at its own log position and was never itself a replacement copy.
*
* The model-visible surface deliberately shadows replaced ranges, so it is the
* wrong source for a human transcript — a landed replacement would erase
* conversation the user already saw. Append-origin events are that transcript's
* durable source material; replacement copies stay model-only.
* @param event - event to test.
* @returns true when the event appended to the surface tail.
*/
export function isAppendSurfaceEvent(
event: SessionEvent,
): event is SurfaceEvent & { surfaceOp: 'append' } {
return isSurfaceEvent(event) && event.surfaceOp === 'append'
}
/**
* Narrow an event to a surface replacement: a node that shadowed an existing
* surface range instead of appending to the tail. The counterpart of
* {@link isAppendSurfaceEvent} over the two {@link SurfaceOp} variants.
* @param event - event to test.
* @returns true when the event replaced a surface range.
*/
export function isReplacementSurfaceEvent(
event: SessionEvent,
): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
return isSurfaceEvent(event) && event.surfaceOp !== 'append'
}
/** One replacement operation observed while folding a session surface. */
export interface SurfaceFoldReplacement {
/** Seq of the event that replaced the prior surface range. */
@@ -212,13 +241,14 @@ function assertToolResultRewrite(
event: SessionEvent,
shadowedSeqs: readonly number[],
events: readonly SessionEvent[],
baseSeq: number,
): void {
if (event.type !== 'tool/result') return
if (shadowedSeqs.length !== 1) {
throw new Error('tool/result surface replacement must rewrite exactly one current node')
}
for (const originalSeq of shadowedSeqs) {
const original = events[originalSeq]
const original = events[originalSeq - baseSeq]
if (original?.type !== 'tool/result') {
throw new Error('tool/result surface replacement must target a current tool/result')
}
@@ -246,6 +276,7 @@ function planSurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
@@ -258,7 +289,7 @@ function planSurfaceEvent(
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
assertToolResultRewrite(event, range.shadowedSeqs, events)
assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq)
return {
kind: 'replace',
seq: event.seq,
@@ -274,8 +305,9 @@ function applySurfaceEvent(
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
baseSeq: number,
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq, events)
const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq)
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -301,7 +333,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const [index, event] of events.entries()) {
const replacement = applySurfaceEvent(state, event, index, events)
const replacement = applySurfaceEvent(state, event, index, events, 0)
if (replacement !== undefined) replacements.push(replacement)
}
return { nodes: [...state.nodes], replacements }
@@ -311,38 +343,55 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
export class SurfaceManager implements SessionSurface {
/** Shared transition state; replacement history is not retained. */
private _state = createFoldState()
/** Last processed seq; -1 folds a seeded log on first access. */
private _lastProcessedSeq = -1
/** Last processed absolute seq. */
private _lastProcessedSeq: number
constructor(private log: readonly SessionEvent[]) {}
/**
* @param log - Contiguous complete log or loaded event window.
* @param baseSeq - Absolute sequence of the window's first event.
*/
constructor(
private log: readonly SessionEvent[],
private readonly baseSeq = 0,
) {
this._lastProcessedSeq = baseSeq - 1
}
/**
* Validate the next candidate without mutating the committed surface.
* @param event - candidate event that has not entered the log yet.
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
planSurfaceEvent(this._state, event, this.log.length, this.log)
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
planSurfaceEvent(
this._state,
event,
this.baseSeq + this.log.length,
this.log,
this.baseSeq,
)
}
/** Monotonic count of folded positional replacements. */
get replaceGeneration(): number {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.replaceGeneration
}
/** Surface event sequences in model-visible order. */
get nodes(): readonly number[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
return this._state.nodes
}
/** Fold events appended since the previous access. */
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
const tailSeq = this.baseSeq + this.log.length - 1
for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) {
const index = seq - this.baseSeq
// oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq)
this._lastProcessedSeq = seq
}
}
}

View File

@@ -3,8 +3,8 @@ import type {
AssistantMessage,
CallId,
LlmCallConfig,
LlmCallConfigAdapterDefaults,
LlmFailure,
MessageSource,
StreamChunk,
TokenUsage,
ToolResultMessage,
@@ -58,6 +58,11 @@ export interface SessionHeader {
* boundary lets resume and replay distinguish parent history from child work.
*/
readonly seedLength?: number
/**
* Coarse product classification for a session created as a subagent child.
* This is presentation metadata, not proof that the child is continuable.
*/
readonly origin?: 'subagent'
/**
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
* for a subagent child. Persisted so a recursion budget survives restart and
@@ -83,28 +88,20 @@ export interface CreateSessionOptions {
readonly parentSession?: SessionId
readonly createdAt?: number
readonly seedLength?: number
readonly origin?: 'subagent'
readonly delegationDepth?: number
}
}
/**
* What started a turn.
* Merge-extensible sum type (same pattern as MessageSourceMap).
*/
export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/** Recovery turn reopened over the repaired current session log. */
retry: { kind: 'retry' }
/**
* An out-of-band producer explicitly enclosed injected context in a one-shot
* turn. `Agent.inject()` appends idle context directly and does not use this
* trigger; the source mirrors the producer of the enclosed `user/message`.
*/
injection: { kind: 'injection'; source: MessageSource }
}
/** Why an active agent driver was cancelled. */
export type AgentCancelCause =
| { readonly kind: 'user' }
| { readonly kind: 'parent' }
| { readonly kind: 'hook'; readonly reason: string }
| { readonly kind: 'disposed' }
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
/** Durable cancellation cause, including imports whose original coarse record carried no cause. */
export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' }
/**
* Why a turn ended. Merge-extensible sum type.
@@ -112,20 +109,15 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
/** A cancellation request interrupted the live turn. */
aborted: { kind: 'aborted' }
aborted: { kind: 'aborted'; reason: TurnEndCancelCause }
blocked: { kind: 'blocked' }
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts
* as one `failure`; other thrown values retain their rendered message and a
* real `HarnessError` code when present.
* The turn failed. `error` is always a structured failure: the `LlmError`
* facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`
* flattened from any other error.
*/
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }
| { message: string; code?: string; failure?: never }
)
disposed: { kind: 'disposed' }
error: { kind: 'error'; error: LlmFailure }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
@@ -163,12 +155,24 @@ export interface TodoItem {
export interface EpochHeader {
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
config: LlmCallConfig
/** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
adapterDefaults?: LlmCallConfigAdapterDefaults
/** Rendered system prompt text; absent for a system-less request. */
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
}
/** Registration-bound metadata for one resolved model route. */
export interface RequestContext {
/** Registered provider route the metadata belongs to. */
provider: string
/** Provider-owned model id the metadata belongs to. */
model: string
/** Maximum combined request and response context in tokens, when advertised. */
contextWindow?: number
}
/**
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
* header (a new conversation); `'resume'` — a loop instance's first request
@@ -185,14 +189,19 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
*/
export interface SessionEventMap {
/**
* Opens turn `turn`. `trigger` records what started the model loop.
* Opens turn `turn` before the loop claims queued input or runs pre-step.
* Rejection, empty input, cancellation, or failure may close it with no
* step; otherwise the following identified `user/message` event or batch
* records the messages entering the step.
*/
'turn/start': { turn: number; trigger: TurnTrigger }
'turn/start': { turn: number }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* awaits `session/flush` after an ordinary turn ends before claiming the next
* queued item. Success commits the turn; rejection is reported live and does
* not prevent later work.
* Closes turn `turn` with the {@link TurnEndReason} that ended it. A turn
* with no entered step has no `step/start` or `step/end`. The loop does not await a
* flush at turn boundaries: `dsh-session-checkpoint-policy` owns the
* per-request durability checkpoint, and consumers that read storage after
* `whenIdle()` flush themselves. Success commits the turn; rejection is
* reported live and does not prevent later work.
*/
'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
@@ -203,9 +212,8 @@ export interface SessionEventMap {
* A user-role message on the model-visible surface: a direct human prompt
* (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
* notifications, …), or an entered goal continuation round. All three
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
/** Raw stream chunk — token-level replay fidelity. */
@@ -241,8 +249,6 @@ export interface SessionEventMap {
error?: { name: string; code: string }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; message: UserMessage }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**
@@ -250,6 +256,34 @@ export interface SessionEventMap {
* It is log-only; the latest snapshot reconstructs the request header.
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Route metadata for the next request, logged only when the route or capacity
* changes. It does not participate in request reconstruction or header equality.
*/
'request/context': RequestContext
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
* produced none of them. This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
* Locate the LAST one in stored history. A seed already ending in one is not
* re-marked, so reopening an untouched session does not grow its log per
* pickup and the event need not be at the current `firstLiveSeq`.
*
* `Session`'s constructor is the only legitimate writer. The invariant
* companion deliberately constrains nothing here, so a plugin appending one
* would silently classify every live bracket before it as seed history.
*
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because seed history and live work are otherwise
* byte-identical: an unmatched opening marker before this event belongs to
* an ended lifecycle, whatever ended it. NOT a liveness signal about other
* writers — a concurrently live session holds its own boundary elsewhere,
* so tolerating concurrent writers needs a signal beyond the log.
*/
'session/end-seed': Record<string, never>
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
@@ -264,7 +298,6 @@ export type SurfaceEventType =
| 'user/message'
| 'assistant/message'
| 'tool/result'
| 'steering/message'
/**
* A {@link SessionEvent} that is **on** the ordered surface — its
@@ -281,7 +314,7 @@ export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: Surface
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
* - `'append'`: added to the tail — normal path for user/assistant/tool/steering
* - `'append'`: added to the tail — normal path for user/assistant/tool
* messages.
* - `{ op: 'replace', start, end }`: replaces surface nodes from `start`
* (inclusive) through `end` (inclusive) with this node. Both must exist as
@@ -316,7 +349,7 @@ export interface SurfaceIntent {
*
* The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional:
* they only exist on {@link SurfaceEventType} variants (`user/message`,
* `assistant/message`, `tool/result`, `steering/message`).
* `assistant/message`, `tool/result`).
* Non-surface events (boundary markers, chunks, usage, errors) never carry
* surface metadata — the compiler enforces this at `Session.append()`
* call sites.

View File

@@ -86,7 +86,7 @@ describe('packChunkRuns', () => {
['a block-index switch', [...deltaRun('text-delta', 2), ...deltaRun('text-delta', 1, 2, 7)]],
['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(e.seq, e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)],
])('breaks a run on %s (both halves too short to pack)', (_label, events) => {
expect(packChunkRuns(events as SessionEvent[])).toStrictEqual(events)
expect(packChunkRuns(events)).toStrictEqual(events)
})
it('breaks a tool-call run on call-id or name change', () => {

View File

@@ -16,13 +16,13 @@ function userText(session: Session, text: string): void {
/** From-scratch oracle: replay the log into a fresh session and derive. */
function scratch(session: Session): unknown {
return new Session(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages()
return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages()
}
describe('derived-message cache', () => {
it('stays deep-equal to a from-scratch replay derivation as the log grows', () => {
const session = new Session(SessionId('cache-grow'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('cache-grow'))
session.append('turn/start', { turn: 1 })
userText(session, 'one')
expect(session.deriveMessages()).toEqual(scratch(session))
userText(session, 'two')
@@ -54,8 +54,8 @@ describe('derived-message cache', () => {
})
it('rebuilds on a surface replace and still matches scratch', () => {
const session = new Session(SessionId('cache-replace'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('cache-replace'))
session.append('turn/start', { turn: 1 })
userText(session, 'one')
userText(session, 'two')
const beforeReplace = session.deriveMessages()
@@ -72,8 +72,8 @@ describe('derived-message cache', () => {
})
it('returns a fresh array per call: later appends never grow a held snapshot', () => {
const session = new Session(SessionId('cache-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('cache-snapshot'))
session.append('turn/start', { turn: 1 })
userText(session, 'one')
const first = session.deriveMessages()
userText(session, 'two')
@@ -89,8 +89,8 @@ describe('derived-message cache', () => {
describe('Session.deriveEventMessage — the per-event projection', () => {
it('projects one appended event exactly as the full derivation projects its node', () => {
const session = new Session(SessionId('per-event'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('per-event'))
session.append('turn/start', { turn: 1 })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -99,8 +99,8 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
})
it('reuses the logged event\'s already frozen content', () => {
const session = new Session(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1 })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -113,8 +113,8 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
})
it('projects null for events that produce no message (boundaries, empty assistant)', () => {
const session = new Session(SessionId('per-event-null'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('per-event-null'))
session.append('turn/start', { turn: 1 })
const boundary = session.append('step/start', { turn: 1, step: 1 })
expect(session.deriveEventMessage(boundary)).toBeNull()
const empty = session.append('assistant/message', {

View File

@@ -7,6 +7,8 @@ import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
/** Stands in for a plugin's open/close bracket (`compact/start`). */
'test/bracket-open': { id: string }
}
}
@@ -22,7 +24,7 @@ function appendClosedTurn(
text = `hello ${turn}`,
reason: TurnEndReason = { kind: 'completed' },
): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
@@ -31,7 +33,7 @@ function appendClosedTurn(
}
function appendOpenTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `open ${turn}` }],
source: { kind: 'user' },
@@ -50,6 +52,14 @@ function lastSeq(session: Session): number {
return event.seq
}
/** A seeded child's constructor seed: its log minus the end-seed marker. */
function inherited(session: Session): readonly SessionEvent[] {
const events = session.events
const last = events.at(-1)
if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker')
return events.slice(0, -1)
}
describe('SessionStore.fork', () => {
it('forks an empty live session as an empty child with lineage metadata', async () => {
const { ctx, sessions } = await setup()
@@ -57,7 +67,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, undefined, SessionId('empty-child'))
expect(child.events).toEqual([])
expect(inherited(child)).toEqual([])
expect(child.header).toMatchObject({
id: SessionId('empty-child'),
cwd: '/workspace',
@@ -73,7 +83,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(SessionId('parent'), undefined, SessionId('child'))
expect(child.events).toEqual(source.events)
expect(inherited(child)).toEqual(source.events)
expect(child.events).not.toBe(source.events)
expect(child.events[1]).not.toBe(source.events[1])
expect(() => {
@@ -97,8 +107,8 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, undefined, SessionId('log-only-child'))
expect(child.events).toEqual(source.events)
expect(child.events.at(-1)).toMatchObject({
expect(inherited(child)).toEqual(source.events)
expect(inherited(child).at(-1)).toMatchObject({
type: 'test/log-only',
data: { value: 'after execution' },
})
@@ -114,7 +124,7 @@ describe('SessionStore.fork', () => {
const child = sessions.fork(source, firstBoundary, SessionId('child-from-first'))
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1))
expect(child.header.seedLength).toBe(firstBoundary + 1)
expect(child.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
@@ -128,24 +138,45 @@ describe('SessionStore.fork', () => {
const { ctx, sessions } = await setup()
const reasons: TurnEndReason[] = [
{ kind: 'completed' },
{ kind: 'aborted' },
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
{ kind: 'disposed' },
{ kind: 'aborted', reason: { kind: 'user' } },
{ kind: 'error', error: { message: 'model failed', code: 'UNKNOWN' } },
{ kind: 'aborted', reason: { kind: 'disposed' } },
{ kind: 'max-tokens' },
{ kind: 'interrupted' },
]
for (const reason of reasons) {
const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
for (const [index, reason] of reasons.entries()) {
const source = ctx.sessions.create(SessionId(`parent-${index}`))
appendClosedTurn(source, 1, reason.kind, reason)
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`))
const child = sessions.fork(source, lastSeq(source), SessionId(`child-${index}`))
expect(child.events.at(-1)?.type).toBe('turn/end')
expect(inherited(child).at(-1)?.type).toBe('turn/end')
expect(child.header.seedLength).toBe(source.events.length)
}
})
it('marks a bracket the child inherited from a still-running parent', async () => {
// The constructor placement's central claim, unreachable from the
// persistence load path.
const { ctx, sessions } = await setup()
const parent = ctx.sessions.create(SessionId('bracket-parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(parent, 1, 'work')
const open = parent.append('test/bracket-open', { id: 'op-1' })
const child = sessions.fork(parent, undefined, SessionId('bracket-child'))
// Parent: no end-seed event follows the bracket, so its owner treats it as live.
expect(parent.events.at(-1)).toBe(open)
expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false)
// Child: the same bracket is before end-seed, so it belongs to the seed.
const boundary = child.events.at(-1)
expect(boundary).toMatchObject({ type: 'session/end-seed' })
expect(boundary!.seq).toBeGreaterThan(open.seq)
expect(child.firstLiveSeq).toBe(open.seq + 1)
expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
})
it('rejects invalid boundaries before creating a child', async () => {
const { ctx, sessions } = await setup()
const empty = ctx.sessions.create(SessionId('empty'))
@@ -186,7 +217,7 @@ describe('SessionStore.fork', () => {
it('rejects a detached Session object that is not live in ctx.sessions', async () => {
const { sessions } = await setup()
const detached = new Session(SessionId('detached'))
const detached = Session.create(SessionId('detached'))
expect(() => sessions.fork(detached))
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
@@ -195,7 +226,7 @@ describe('SessionStore.fork', () => {
it('rejects a stale Session object whose id is live on a different instance', async () => {
const { ctx, sessions } = await setup()
ctx.sessions.create(SessionId('same-id'))
const stale = new Session(SessionId('same-id'))
const stale = Session.create(SessionId('same-id'))
expect(() => sessions.fork(stale))
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
@@ -205,23 +236,23 @@ describe('SessionStore.fork', () => {
const { ctx, sessions } = await setup()
const cases: [string, (session: Session) => number][] = [
['turn/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
return lastSeq(session)
}],
['step/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
return lastSeq(session)
}],
['user/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
return lastSeq(session)
}],
['assistant/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1, step: 1,
@@ -238,7 +269,7 @@ describe('SessionStore.fork', () => {
}],
['tool/call', (session) => {
const callId = CallId('call-open')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
@@ -279,7 +310,7 @@ describe('SessionStore.fork', () => {
it('rejects a duplicate child session id before validating the boundary', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('open-parent'))
source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
source.append('turn/start', { turn: 1 })
ctx.sessions.create(SessionId('child'))
expect(() => sessions.fork(source, undefined, SessionId('child')))

View File

@@ -26,7 +26,7 @@ describe('session-log invariants', () => {
await scopedCtx.plugin(SessionInvariant)
const session = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
@@ -35,7 +35,7 @@ describe('session-log invariants', () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -78,11 +78,10 @@ describe('session-log invariants', () => {
})
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('later dispatch veto')
expect(session.events).toEqual([])
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
})
@@ -94,7 +93,7 @@ describe('session-log invariants', () => {
const session = ctx.sessions.create(SessionId('postcommit-peer'))
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
expect(warnings).toHaveLength(2)
@@ -107,7 +106,7 @@ describe('session-log invariants', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
} as never)
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, {
type: 'turn/end',
@@ -120,46 +119,63 @@ describe('session-log invariants', () => {
it('enforces turn numbering and core execution enclosure', async () => {
const first = await setup()
const open = first.ctx.sessions.create()
open.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => open.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
open.append('turn/start', { turn: 1 })
expect(() => open.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
.toThrow(/does not match open turn 1/)
const second = (await setup()).ctx.sessions.create()
second.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
second.append('turn/start', { turn: 1 })
second.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => second.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
expect(() => second.append('turn/start', { turn: 3 }))
.toThrow(/expected turn 2, got 3/)
const third = (await setup()).ctx.sessions.create()
third.append('turn/start', { turn: 1 })
third.append('step/start', { turn: 1, step: 1 })
third.append('step/end', { turn: 1, step: 1 })
expect(() => third.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
.not.toThrow()
const enclosed = (await setup()).ctx.sessions.create()
enclosed.append('turn/start', { turn: 1 })
enclosed.append('step/start', { turn: 1, step: 1 })
expect(() => enclosed.append('todo/write', { todos: [] })).not.toThrow()
expect(() => enclosed.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
reason: 'initial',
} as never)).not.toThrow()
expect(() => enclosed.append('request/context', {
provider: 'mock', model: 'mock',
})).not.toThrow()
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'plugin', plugin: 'test' },
}), { surfaceOp: 'append' })).not.toThrow()
expect(() => outside.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Route capacity is core execution state like the header beside it.
expect(() => outside.append('request/context', {
provider: 'mock',
model: 'm',
contextWindow: 128_000,
})).toThrow(/outside any open turn/)
// The owning plugin decides whether a merge-extensible event is log-only.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
expect(() => { appendUnknown('plugin/marker', {}) }).not.toThrow()
expect(() => outside.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
it('enforces open-step identity and numbering', async () => {
const wrongTurn = (await setup()).ctx.sessions.create()
wrongTurn.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
wrongTurn.append('turn/start', { turn: 1 })
expect(() => wrongTurn.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
const nested = (await setup()).ctx.sessions.create()
nested.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
nested.append('turn/start', { turn: 1 })
nested.append('step/start', { turn: 1, step: 1 })
expect(() => nested.append('step/start', { turn: 1, step: 2 })).toThrow(/while step 1 is still open/)
expect(() => nested.append('turn/end', { turn: 1, reason: { kind: 'completed' } }))
@@ -179,16 +195,21 @@ describe('session-log invariants', () => {
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
const skipped = (await setup()).ctx.sessions.create()
skipped.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
skipped.append('turn/start', { turn: 1 })
skipped.append('step/start', { turn: 1, step: 1 })
skipped.append('step/end', { turn: 1, step: 1 })
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
expect(() => skipped.append('turn/end', {
turn: 1,
reason: { kind: 'completed' },
})).not.toThrow()
})
it('requires step-scoped stream and tool events to name the open step', async () => {
const chunk = (await setup()).ctx.sessions.create()
chunk.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
chunk.append('turn/start', { turn: 1 })
expect(() => chunk.append('assistant/chunk', {
turn: 1,
step: 1,
@@ -196,7 +217,7 @@ describe('session-log invariants', () => {
})).toThrow(/open is turn 1\/step null/)
const tool = (await setup()).ctx.sessions.create()
tool.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
tool.append('turn/start', { turn: 1 })
tool.append('step/start', { turn: 1, step: 1 })
expect(() => tool.append('tool/result', {
turn: 1,
@@ -212,7 +233,7 @@ describe('session-log invariants', () => {
it('keeps fresh tool-result appends open-step checked', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
@@ -227,7 +248,7 @@ describe('session-log invariants', () => {
it('treats a validated tool-result replacement as a turn-enclosed rewrite', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
@@ -248,7 +269,7 @@ describe('session-log invariants', () => {
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 2 })
expect(() => session.append('tool/result', {
...original.data,
message: freezeMessage({
@@ -267,7 +288,7 @@ describe('session-log invariants', () => {
it('rejects a tool-result replacement outside a turn', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
@@ -306,7 +327,7 @@ describe('session-log invariants', () => {
it('allows not-started repair results and unresolved calls at step end', async () => {
const repaired = (await setup()).ctx.sessions.create()
expect(() => {
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
repaired.append('turn/start', { turn: 1 })
repaired.append('step/start', { turn: 1, step: 1 })
repaired.append('tool/result', {
turn: 1,
@@ -324,18 +345,18 @@ describe('session-log invariants', () => {
const unresolved = (await setup()).ctx.sessions.create()
expect(() => {
unresolved.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unresolved.append('turn/start', { turn: 1 })
unresolved.append('step/start', { turn: 1, step: 1 })
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
unresolved.append('step/end', { turn: 1, step: 1 })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
unresolved.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
}).not.toThrow()
})
it('does not let a result in a later step satisfy an earlier call', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('step/end', { turn: 1, step: 1 })
@@ -354,22 +375,22 @@ describe('session-log invariants', () => {
it('replays seeded sessions and tracks each session independently', async () => {
const { ctx } = await setup()
const badSeed = [
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1 } },
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2 } },
]
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
const a = ctx.sessions.create(SessionId('a'))
const b = ctx.sessions.create(SessionId('b'))
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
a.append('turn/start', { turn: 1 })
expect(() => b.append('turn/start', { turn: 1 }))
.not.toThrow()
})
it('rebuilds trace state for sessions that exist when the companion reloads', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
await fiber.dispose()
await ctx.plugin(SessionInvariant)
@@ -378,18 +399,35 @@ describe('session-log invariants', () => {
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'h' },
})).not.toThrow()
expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
expect(() => session.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
})
it('accepts end-seed whether or not a turn is open', async () => {
const { ctx } = await setup()
// Balanced seed: between turns.
expect(() => ctx.sessions.create(SessionId('inherited-between-turns'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] })).not.toThrow()
// Unbalanced seed: inside the open turn, which the relation permits.
const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
] })
expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed'])
// Still open afterwards: the boundary moves no cursor.
expect(() => open.append('turn/start', { turn: 2 }))
.toThrow(/turn 1 is still open/)
expect(() => open.append('turn/end', { turn: 1, reason: { kind: 'completed' } })).not.toThrow()
})
it('removes all listeners when the companion is disposed', async () => {
const { ctx, fiber } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
await fiber.dispose()
expect(() => session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})).not.toThrow()
})
})

View File

@@ -70,7 +70,7 @@ const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
// A non-message event (trace/replay data — must NOT affect derived history).
const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
fc.constant<Appendable>({ type: 'turn/start', data: { turn: 1 } }),
fc.constant<Appendable>({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
@@ -82,7 +82,7 @@ const logArb = fc.array(anyEventArb, { maxLength: 25 })
let counter = 0
function build(events: Appendable[]): Session {
const session = new Session(SessionId(`prop-${counter++}`))
const session = Session.create(SessionId(`prop-${counter++}`))
for (const e of events) {
// Forward the generated intent verbatim; non-surface events carry none.
if (e.intent !== undefined) session.append(e.type, e.data, e.intent)
@@ -110,9 +110,21 @@ describe('Session properties', () => {
it('replay-from-seed reproduces the derivation identically', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
expect(replayed.seq).toBe(original.seq)
// Every explicit replay grows by exactly one log-only boundary.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.seq).toBe(original.seq + 1)
}))
})
it('replaying a log that already ends in end-seed adds no further marker', () => {
fc.assert(fc.property(logArb, (events) => {
const original = build(events)
const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.events])
const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.events])
// Lazy resume makes browsing a pickup, so this must not grow per open.
expect(twice.events).toEqual(once.events)
}))
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import { interruptedTurnClosers, lastActivityTime, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
/**
@@ -13,7 +13,7 @@ import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
*/
const userTurnStart = (turn: number, seq: number): SessionEvent =>
({ type: 'turn/start', seq, time: seq, data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
({ type: 'turn/start', seq, time: seq, data: { turn } })
describe('interruptedTurnClosers', () => {
it('returns nothing for a balanced log (ends on turn/end)', () => {
@@ -273,3 +273,44 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
describe('lastActivityTime', () => {
const endSeedAt = (seq: number, time: number): SessionEvent =>
({ type: 'session/end-seed', seq, time, data: {} })
it('has no answer for an empty log', () => {
expect(lastActivityTime([])).toBeUndefined()
})
it('reports the log tail when no boundary is present', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(500)
})
it('skips a trailing boundary in favour of the last real work', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 500, data: { turn: 1, reason: { kind: 'completed' } } },
endSeedAt(2, 9_000),
]
// Resumed long after the work, but never worked in again.
expect(lastActivityTime(events)).toBe(500)
})
it('reports work appended after end-seed', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
endSeedAt(1, 9_000),
{ type: 'turn/end', seq: 2, time: 9_500, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(lastActivityTime(events)).toBe(9_500)
})
it('has no answer for a log of nothing but boundaries', () => {
// Unreachable via the constructor, but the projection is a pure function.
expect(lastActivityTime([endSeedAt(0, 1), endSeedAt(1, 2)])).toBeUndefined()
})
})

View File

@@ -14,9 +14,24 @@ function tool(name: string, description = 'd'): ToolSchema {
describe('canonicalHeader', () => {
it('normalizes empty optional fields to absence and preserves populated fields', () => {
expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')] })
expect(canonicalHeader({
config: CONFIG,
adapterDefaults: {},
system: '',
tools: [],
})).toEqual({ config: CONFIG })
const full = canonicalHeader({
config: { ...CONFIG, maxTokens: 256_000 },
adapterDefaults: { maxTokens: true },
system: 's',
tools: [tool('a')],
})
expect(full).toEqual({
config: { ...CONFIG, maxTokens: 256_000 },
adapterDefaults: { maxTokens: true },
system: 's',
tools: [tool('a')],
})
})
})
@@ -30,6 +45,14 @@ describe('headerEquals', () => {
...base,
config: { ...base.config, reasoningEffort: ReasoningEffortId('high') },
})).toBe(false)
expect(headerEquals(
{ ...base, config: { ...base.config, maxTokens: 256_000 } },
{
...base,
config: { ...base.config, maxTokens: 256_000 },
adapterDefaults: { maxTokens: true },
},
)).toBe(false)
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false)
@@ -45,15 +68,15 @@ describe('foldRequestHeader', () => {
it('returns the supplied baseline when no snapshot follows', () => {
const from: EpochHeader = { config: CONFIG, system: 'baseline' }
const unrelated: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
]
expect(foldRequestHeader(unrelated)).toBeUndefined()
expect(foldRequestHeader(unrelated, from)).toBe(from)
})
it('takes the latest full snapshot and skips unrelated events', () => {
const session = new Session(SessionId('fold'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('fold'))
session.append('turn/start', { turn: 1 })
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
@@ -68,9 +91,9 @@ describe('legacy request-header format', () => {
const legacy = [{
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
expect(() => Session.create(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
const session = new Session(SessionId('legacy-append-delta'))
const session = Session.create(SessionId('legacy-append-delta'))
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
expect(() => appendLegacy('request/header-delta', { config: CONFIG }))
.toThrow(/unsupported legacy request\/header-delta/)
@@ -81,13 +104,70 @@ describe('legacy request-header format', () => {
const legacy = [{
type: 'request/header', seq: 0, time: 1, data: { header: { config: CONFIG }, reason: 'fallback' },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy-seed-reason'), legacy))
expect(() => Session.create(SessionId('legacy-seed-reason'), legacy))
.toThrow('unsupported legacy request/header reason "fallback"')
const session = new Session(SessionId('legacy-append-reason'))
const session = Session.create(SessionId('legacy-append-reason'))
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' }))
.toThrow('unsupported legacy request/header reason "fallback"')
expect(session.events).toHaveLength(0)
})
})
describe('Session.requestContext', () => {
const CAPACITY = { provider: 'mock', model: 'm', contextWindow: 128_000 }
/** A turn-enclosed capacity record; the invariant rejects one outside a turn. */
function seedWith(...records: { provider: string; model: string; contextWindow?: number }[]): SessionEvent[] {
const events: SessionEvent[] = [{
type: 'turn/start', seq: 0, time: 1, data: { turn: 1 },
}]
for (const data of records) {
events.push({ type: 'request/context', seq: events.length, time: 1, data })
}
return events
}
it('reads undefined before any record exists', () => {
expect(Session.create(SessionId('no-capacity')).requestContext()).toBeUndefined()
})
it('folds a seeded log on first read, taking the last record', () => {
// The fold watermark starts at 0 with the seed already in the log, so the
// first read must consume the whole seed rather than skip it.
const session = Session.create(SessionId('seeded-capacity'), seedWith(
CAPACITY,
{ ...CAPACITY, model: 'later', contextWindow: 256_000 },
))
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'later', contextWindow: 256_000 })
})
it('advances incrementally across appends and skips unrelated events', () => {
const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('todo/write', { todos: [] })
expect(session.requestContext()).toEqual(CAPACITY)
session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 })
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 })
session.append('request/context', { provider: 'mock', model: 'unknown' })
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'unknown' })
})
it('folds a batch appended between two reads', () => {
const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
session.append('todo/write', { todos: [] })
session.append('request/context', { ...CAPACITY, contextWindow: 300_000 })
expect(session.requestContext()?.contextWindow).toBe(300_000)
})
it('exposes a frozen record so a reader cannot desync later comparisons', () => {
const session = Session.create(SessionId('frozen-capacity'), seedWith(CAPACITY))
const held = session.requestContext()
if (held === undefined) throw new Error('expected a folded capacity record')
expect(Object.isFrozen(held)).toBe(true)
expect(() => { (held as { contextWindow?: number }).contextWindow = 1 }).toThrow()
})
})

View File

@@ -40,7 +40,7 @@ describe('session dispatch carriers', () => {
otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
const session = scope.ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
expect(heard).toEqual([
`owner-created:${session.id}`,
@@ -57,7 +57,7 @@ describe('session dispatch carriers', () => {
scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
const bare = ctx.sessions.create()
bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
bare.append('turn/start', { turn: 1 })
expect(heard).toEqual(['global:turn/start'])
})
@@ -80,6 +80,24 @@ describe('session dispatch carriers', () => {
})
describe('sessions.flush()', () => {
it('allows an ordinary flush with no listeners', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
await expect(ctx.sessions.flush(session)).resolves.toBe(false)
})
it('reports a participating listener after it succeeds', async () => {
const ctx = await mount()
const session = ctx.sessions.create()
const flushed: Session[] = []
ctx.on('session/flush', current => void flushed.push(current))
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
expect(flushed).toEqual([session])
})
it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'owner')

View File

@@ -2,18 +2,35 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
findLastMessageTurnEnd,
adoptSessionEvent,
SESSION_FORMAT_VERSION,
Session,
SessionEvent,
SessionId,
findLastMessageTurnEnd,
snapshotSessionEvent,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('finds the latest closed turn that entered a model step', () => {
const session = Session.create(SessionId('last-message-turn'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', { turn: 2 })
session.append('step/start', { turn: 2, step: 1 })
session.append('step/end', { turn: 2, step: 1 })
session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
expect(findLastMessageTurnEnd(session.events)?.data)
.toEqual({ turn: 2, reason: { kind: 'max-tokens' } })
})
it('exposes one stable readonly surface view', () => {
const session = new Session(SessionId('surface-view'))
const session = Session.create(SessionId('surface-view'))
const surface = session.surface
expectTypeOf(surface).toEqualTypeOf<SessionSurface>()
@@ -21,8 +38,8 @@ describe('Session', () => {
})
it('derives message history from the event log', () => {
const session = new Session(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -61,8 +78,8 @@ describe('Session', () => {
it('accepts and round-trips a max-tokens turn/end reason', () => {
// The max-tokens TurnEndReason variant carries no extra data, so it must
// append and persist like any other reason (JSON-serializable, no fields).
const session = new Session(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('s1'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
const turnEnd = session.events.findLast(e => e.type === 'turn/end')!
@@ -71,81 +88,27 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('finds the latest message-turn outcome past later non-message turns', () => {
const session = new Session(SessionId('message-turn-outcome'))
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'bounded prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
})
it('round-trips the coarse aborted turn outcome', () => {
const session = new Session(SessionId('aborted'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events).toEqual(session.events)
it('round-trips an aborted turn with its cancellation cause', () => {
const session = Session.create(SessionId('aborted'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } })
const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events))
expect(replayed.events.slice(0, -1)).toEqual(session.events)
const turnEnd = replayed.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
})
it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => {
const legacy = [
{
type: 'turn/start', seq: 0, time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'turn/end', seq: 1, time: 2,
data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } },
},
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy-aborted'), legacy))
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
})
it('renders injected-context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
it('renders injected-context and user messages as plain user content', () => {
const session = Session.create(SessionId('s2'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
}), { surfaceOp: 'append' })
session.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const [contextMessage, steeringMessage] = session.deriveMessages()
expect(contextMessage!.role).toBe('user')
@@ -155,7 +118,7 @@ describe('Session', () => {
})
it('keeps the exact identified context message in durable history and projection', () => {
const session = new Session(SessionId('s2-raw'))
const session = Session.create(SessionId('s2-raw'))
const message = createUserMessage({
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
@@ -168,8 +131,8 @@ describe('Session', () => {
})
it('replays identically from a seeded event log', () => {
const original = new Session(SessionId('s3'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const original = Session.create(SessionId('s3'))
original.append('turn/start', { turn: 1 })
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -186,9 +149,27 @@ describe('Session', () => {
}, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
const replayed = Session.create(SessionId('s3-replay'), [...original.events])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
expect(replayed.seq).toBe(original.seq)
// The seed verbatim, plus the end-seed event the constructor appends.
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.seq).toBe(original.seq + 1)
expect(replayed.firstLiveSeq).toBe(original.seq)
})
it('marks an explicitly empty seed without marking a fresh session', () => {
const fresh = Session.create(SessionId('fresh-empty'))
expect(fresh.events).toEqual([])
const resumed = Session.create(SessionId('resumed-empty'), [])
expect(resumed.firstLiveSeq).toBe(0)
expect(resumed.events).toMatchObject([
{ type: 'session/end-seed', seq: 0, data: {} },
])
const reopened = Session.create(SessionId('reopened-empty'), resumed.events)
expect(reopened.firstLiveSeq).toBe(1)
expect(reopened.events).toEqual(resumed.events)
})
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
@@ -196,7 +177,7 @@ describe('Session', () => {
type: 'request/header', seq: 0, time: 1,
data: { header: { config: { model: 'old-model' } }, reason: 'initial' },
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-header'), [requestHeader]))
expect(() => Session.create(SessionId('old-header'), [requestHeader]))
.toThrow('seed request/header at index 0 lacks provider/model')
const assistantMessage = {
@@ -204,20 +185,20 @@ describe('Session', () => {
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'old' }] },
surfaceOp: 'append',
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
expect(() => Session.create(SessionId('old-assistant'), [assistantMessage]))
.toThrow('seed assistant/message at index 0 lacks an identified message')
const malformedHeader = {
type: 'request/header', seq: 0, time: 1,
data: { header: 'old-header' },
} as unknown as SessionEvent
expect(() => new Session(SessionId('malformed-header'), [malformedHeader]))
expect(() => Session.create(SessionId('malformed-header'), [malformedHeader]))
.toThrow('seed request/header at index 0 lacks provider/model')
const unrelatedPrimitiveData = {
type: 'plugin/event', seq: 0, time: 1, data: null,
} as unknown as SessionEvent
expect(new Session(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events)
expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1))
.toEqual([unrelatedPrimitiveData])
})
@@ -269,6 +250,14 @@ describe('Session', () => {
},
message: 'message has invalid source',
},
{
name: 'content shape',
event: {
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
data: { ...user, content: 'not-an-array' },
},
message: 'message has invalid content',
},
{
name: 'assistant source',
event: {
@@ -281,17 +270,6 @@ describe('Session', () => {
},
message: 'message must have model source',
},
{
name: 'content block',
event: {
type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
message: { ...user, content: 'not-an-array' },
},
},
message: 'message has invalid content',
},
{
name: 'tool source',
event: {
@@ -335,7 +313,7 @@ describe('Session', () => {
for (const { name, event, message } of invalid) {
expect(
() => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
() => Session.create(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
name,
).toThrow(message)
}
@@ -346,13 +324,13 @@ describe('Session', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
})
expect(boundary).toEqual({
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
})
const extended = snapshotSessionEvent({
@@ -371,6 +349,45 @@ describe('Session', () => {
.toEqual([{ type: 'plugin-block', value: 1 }])
})
it('adopts exclusively owned messages in place and keeps snapshots detached', () => {
const owned = {
type: 'user/message',
seq: 0,
time: 1,
surfaceOp: 'append',
data: {
id: 'owned-message',
role: 'user',
content: [{ type: 'text', text: 'owned' }],
source: { kind: 'user' },
},
} as SessionEvent<'user/message'>
expect(adoptSessionEvent(owned)).toBe(owned)
expect(Object.isFrozen(owned.data)).toBe(true)
expect(Object.isFrozen(owned.data.content)).toBe(true)
const source = structuredClone(owned)
const snapshot = snapshotSessionEvent(source)
expect(snapshot).not.toBe(source)
expect(snapshot.data).not.toBe(source.data)
expect(snapshot.data.content).not.toBe(source.data.content)
})
it('validates message shape before adopting ownership', () => {
const malformed = {
type: 'user/message',
seq: 0,
time: 1,
data: {
id: 'wrong-role',
role: 'assistant',
content: [],
source: { kind: 'user' },
},
} as unknown as SessionEvent
expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"')
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',
@@ -387,7 +404,7 @@ describe('Session', () => {
reason: 'initial',
},
} as const
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0])
.toEqual(valid)
for (const reasoningEffort of ['', 1]) {
@@ -395,13 +412,47 @@ describe('Session', () => {
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
const config = invalid.data.header.config as unknown as Record<string, unknown>
config.reasoningEffort = reasoningEffort
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
expect(() => Session.create(SessionId('invalid-reasoning-effort'), [invalid]))
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
}
})
it('round-trips adapter-default provenance and rejects invalid durable values', () => {
const valid = {
type: 'request/header',
seq: 0,
time: 1,
data: {
header: {
config: {
provider: 'mock',
model: 'model',
maxTokens: 256_000,
},
adapterDefaults: { maxTokens: true },
},
reason: 'initial',
},
} as const
expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
for (const adapterDefaults of [
null,
[],
{ unknown: true },
{ maxTokens: false },
{ reasoningEffort: true },
]) {
const invalid = structuredClone(valid) as unknown as SessionEvent
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
invalid.data.header.adapterDefaults = adapterDefaults as never
expect(() => Session.create(SessionId('invalid-adapter-defaults'), [invalid]))
.toThrow('seed request/header at index 0 has invalid adapterDefaults')
}
})
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
const session = Session.create(SessionId('s4'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -435,7 +486,7 @@ describe('Session', () => {
})
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
const session = new Session(SessionId('s5'))
const session = Session.create(SessionId('s5'))
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never, { surfaceOp: 'append' })
expect(bad(1n)).toThrow(/non-JSON-serializable/)
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
@@ -462,8 +513,8 @@ describe('Session', () => {
})
it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => {
const session = new Session(SessionId('s5b'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('s5b'))
session.append('turn/start', { turn: 1 })
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
const widenedType = 'user/message' as SessionEventType
@@ -476,7 +527,7 @@ describe('Session', () => {
})
it('accepts dense arrays and nested plain objects', () => {
const session = new Session(SessionId('s6'))
const session = Session.create(SessionId('s6'))
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow()
expect(session.events).toHaveLength(1)
})
@@ -487,15 +538,15 @@ describe('Session', () => {
const badSeed = [
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
expect(() => Session.create(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
})
it('validates seed events: rejects a non-contiguous seq', () => {
const gapSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
] as SessionEvent[]
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
expect(() => Session.create(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
})
it('validates seed events: rejects a surface-eligible event missing its surfaceOp marker', () => {
@@ -504,25 +555,26 @@ describe('Session', () => {
// so a resume/fork would silently lose history. append() forbids this at
// compile time; a raw seed must be rejected at runtime to match.
const markerlessSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}) },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
expect(() => Session.create(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
})
it('accepts a well-formed contiguous serializable seed', () => {
const goodSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}), surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
expect(session.events).toHaveLength(3)
const session = Session.create(SessionId('seed-ok'), goodSeed)
expect(session.events.slice(0, 3)).toEqual(goodSeed)
expect(session.firstLiveSeq).toBe(3)
})
it('reads each seed array entry once so validation and storage use the same event', () => {
@@ -530,7 +582,7 @@ describe('Session', () => {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
data: { turn: 1 },
}
const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
let reads = 0
@@ -543,10 +595,10 @@ describe('Session', () => {
},
})
const session = new Session(SessionId('seed-entry-snapshot'), seed)
const session = Session.create(SessionId('seed-entry-snapshot'), seed)
expect(reads).toBe(1)
expect(session.events).toEqual([accepted])
expect(session.events.slice(0, 1)).toEqual([accepted])
})
it('reads a nested seed-data getter once and stores its first JSON value', () => {
@@ -560,7 +612,7 @@ describe('Session', () => {
})
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-nested-drift'), seed)
const session = Session.create(SessionId('seed-nested-drift'), seed)
expect(reads).toBe(1)
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
@@ -577,7 +629,7 @@ describe('Session', () => {
surfaceOp: { op: 'replace', start: 1n, end: 2 },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
expect(() => Session.create(SessionId('seed-bad-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
@@ -597,7 +649,7 @@ describe('Session', () => {
surfaceOp: new ReplaceOp(),
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
expect(() => Session.create(SessionId('seed-exotic-metadata'), seed))
.toThrow(/losslessly JSON-serializable/)
})
@@ -606,11 +658,11 @@ describe('Session', () => {
readonly type = 'turn/start' as const
readonly seq = 0
readonly time = 1
readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
readonly data = { turn: 1 }
}
const seed: SessionEvent[] = [new SeedEvent()]
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
expect(() => Session.create(SessionId('seed-exotic-shell'), seed))
.toThrow(/not losslessly JSON-serializable/)
})
@@ -619,12 +671,12 @@ describe('Session', () => {
type: 'turn/start' as const,
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
data: { turn: 1 },
}) as unknown as SessionEvent
const session = new Session(SessionId('seed-null-prototype'), [event])
const session = Session.create(SessionId('seed-null-prototype'), [event])
expect(session.events).toEqual([{ ...event }])
expect(session.events.slice(0, 1)).toEqual([{ ...event }])
})
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
@@ -655,7 +707,7 @@ describe('Session', () => {
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
const session = new Session(SessionId('seed-unstable-metadata'), seed)
const session = Session.create(SessionId('seed-unstable-metadata'), seed)
const event = session.events[1]!
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
@@ -692,7 +744,7 @@ describe('Session', () => {
}] as unknown as SessionEvent[]
try {
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
expect(() => Session.create(SessionId('seed-non-error-metadata-failure'), seed))
.toThrow(`invalid seed event at index 1: ${expected}`)
} finally {
hasOwn.mockRestore()
@@ -701,7 +753,7 @@ describe('Session', () => {
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
const seed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } },
{ type: 'user/message' as const, seq: 1, time: 2, data: {
id: MessageId('seed-input'),
role: 'user' as const,
@@ -709,7 +761,7 @@ describe('Session', () => {
}, surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-snapshot'), seed)
const session = Session.create(SessionId('seed-snapshot'), seed)
// Mutate the ORIGINAL seed objects after construction: a shared reference
// would let this rewrite the forked log (or reintroduce non-serializable
// data past validation). The snapshot must shield session.events.
@@ -722,7 +774,7 @@ describe('Session', () => {
})
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
const session = new Session(SessionId('append-snapshot'))
const session = Session.create(SessionId('append-snapshot'))
const data = {
id: MessageId('append-input'),
role: 'user' as const,
@@ -742,7 +794,7 @@ describe('Session', () => {
})
it('reads a nested append-data getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-nested-drift'))
const session = Session.create(SessionId('append-nested-drift'))
let reads = 0
const data = Object.defineProperty({}, 'value', {
enumerable: true,
@@ -760,7 +812,7 @@ describe('Session', () => {
})
it('rejects non-JSON surface metadata before appending the event', () => {
const session = new Session(SessionId('append-bad-metadata'))
const session = Session.create(SessionId('append-bad-metadata'))
expect(() => session.append(
'user/message',
@@ -778,7 +830,7 @@ describe('Session', () => {
readonly start = 0
readonly end = 0
}
const session = new Session(SessionId('append-exotic-metadata'))
const session = Session.create(SessionId('append-exotic-metadata'))
expect(() => session.append(
'user/message',
@@ -791,7 +843,7 @@ describe('Session', () => {
})
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
const session = new Session(SessionId('append-unstable-metadata'))
const session = Session.create(SessionId('append-unstable-metadata'))
const source = session.append(
'user/message',
createUserMessage({
@@ -822,7 +874,7 @@ describe('Session', () => {
})
it('rejects invalid plain surface metadata shapes at append', () => {
const session = new Session(SessionId('append-invalid-surface-shape'))
const session = Session.create(SessionId('append-invalid-surface-shape'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
@@ -843,7 +895,7 @@ describe('Session', () => {
})
it('rejects surface metadata on non-surface append and seed events', () => {
const session = new Session(SessionId('non-surface-metadata'))
const session = Session.create(SessionId('non-surface-metadata'))
const appendRaw = session.append.bind(session) as unknown as (
type: SessionEventType,
data: unknown,
@@ -852,34 +904,33 @@ describe('Session', () => {
expect(() => appendRaw(
'turn/start',
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
{ turn: 1 },
{ surfaceOp: 'append' },
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
expect(() => Session.create(SessionId('non-surface-metadata-seed'), [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
surfaceOp: 'append',
} as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
expect(session.events).toEqual([])
})
it('deep-freezes seeded and appended event snapshots', () => {
const seeded = new Session(SessionId('seed-frozen'), [{
const seeded = Session.create(SessionId('seed-frozen'), [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
}])
const seededEvent = seeded.events[0]!
if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
expect(Object.isFrozen(seededEvent)).toBe(true)
expect(Object.isFrozen(seededEvent.data)).toBe(true)
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
const appended = new Session(SessionId('append-frozen'))
const appended = Session.create(SessionId('append-frozen'))
const appendedEvent = appended.append('todo/write', {
todos: [{ content: 'first', status: 'pending' }],
})
@@ -891,8 +942,8 @@ describe('Session', () => {
})
it('returns cached frozen event-array snapshots that do not grow after append', () => {
const session = new Session(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const session = Session.create(SessionId('events-snapshot'))
session.append('turn/start', { turn: 1 })
const before = session.events
const beforeEvent = before[0]!
if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
@@ -920,7 +971,7 @@ describe('Session', () => {
seedLength: 2,
}
const session = new Session(SessionId('header-owned'), undefined, input)
const session = Session.create(SessionId('header-owned'), undefined, input)
input.cwd = '/caller-mutated'
expect(session.header).toEqual({
@@ -945,15 +996,15 @@ describe('Session', () => {
readonly createdAt = 123
}
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
expect(() => Session.create(SessionId('header-invalid'), undefined, new ExoticHeader()))
.toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
expect(() => Session.create(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('header-invalid'),
createdAt: 123,
parentSession: 1n,
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
expect(() => new Session(SessionId('header-invalid'), undefined, {
expect(() => Session.create(SessionId('header-invalid'), undefined, {
version: SESSION_FORMAT_VERSION,
id: SessionId('other'),
createdAt: 123,
@@ -980,7 +1031,7 @@ describe('Session', () => {
]
for (const { header, error } of cases) {
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
expect(() => Session.create(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
}
})
@@ -989,7 +1040,7 @@ describe('Session', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
}
const cases: unknown[] = [
{ ...base, extra: true },
@@ -1004,7 +1055,7 @@ describe('Session', () => {
]
for (const [index, event] of cases.entries()) {
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.toThrow(/invalid event envelope/)
}
})
@@ -1028,7 +1079,7 @@ describe('SessionStore', () => {
// may create an unrelated property with the old implementation's name,
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1046,7 +1097,7 @@ describe('SessionStore', () => {
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('turn/start', { turn: 1 })
a.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1092,7 +1143,7 @@ describe('SessionStore', () => {
const secondCtx = new Context()
await firstCtx.plugin(SessionStore)
await secondCtx.plugin(SessionStore)
const session = new Session(SessionId('owned-key'))
const session = Session.create(SessionId('owned-key'))
const detachFirst = firstCtx.sessions.enter(session)
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
@@ -1198,15 +1249,16 @@ describe('SessionStore', () => {
})
})
it('attaches delegationDepth from meta to the header', async () => {
it('attaches subagent origin and delegationDepth from meta to the header', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('delegated-child'), {
meta: { parentSession: SessionId('parent'), delegationDepth: 2 },
meta: { parentSession: SessionId('parent'), origin: 'subagent', delegationDepth: 2 },
})
expect(session.header).toMatchObject({
id: 'delegated-child',
parentSession: 'parent',
origin: 'subagent',
delegationDepth: 2,
})
})
@@ -1225,6 +1277,7 @@ describe('SessionStore', () => {
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
{ meta: { origin: 'fork' }, error: /origin must be "subagent"/ },
{ meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ },
{ meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ },
@@ -1247,7 +1300,7 @@ describe('SessionStore', () => {
})
it('a bare Session() constructed without the store still exposes a current-version header', () => {
const session = new Session(SessionId('bare'))
const session = Session.create(SessionId('bare'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
expect(typeof session.header.createdAt).toBe('number')
})
@@ -1295,7 +1348,7 @@ describe('SessionStore', () => {
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1321,7 +1374,6 @@ describe('SessionStore', () => {
expect(() => {
appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
}).not.toThrow()
expect(committedBeforeNotify).toBe(true)
@@ -1360,14 +1412,12 @@ describe('SessionStore', () => {
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('reject first candidate')
expect(session.events).toEqual([])
expect(observed).toEqual([])
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
{ logLength: 0, frozen: true },
@@ -1383,7 +1433,7 @@ describe('SessionStore', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }],
@@ -1438,7 +1488,6 @@ describe('SessionStore', () => {
expect(() => session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})).toThrow('dispatch instrumentation rejected the carrier')
expect(session.events).toEqual([])
expect(observed).toEqual([])
@@ -1458,7 +1507,6 @@ describe('SessionStore', () => {
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
expect(heard).toEqual([appended])
@@ -1489,7 +1537,6 @@ describe('SessionStore', () => {
const appended = session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(session.events).toEqual([appended])
@@ -1571,7 +1618,7 @@ describe('SessionStore', () => {
it('does not let internal dispatch replace the disposed callback tuple', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const replacement = new Session(SessionId('replacement-disposed'))
const replacement = Session.create(SessionId('replacement-disposed'))
const heard: Session[] = []
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name === 'session/disposed') args[0] = replacement
@@ -1589,7 +1636,7 @@ describe('SessionStore', () => {
describe('todo/write event', () => {
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
const session = new Session(SessionId('t1'))
const session = Session.create(SessionId('t1'))
const todos: TodoItem[] = [
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
@@ -1611,7 +1658,7 @@ describe('todo/write event', () => {
})
it('is last-write-wins: the current list is the most recent todo/write', () => {
const session = new Session(SessionId('t2'))
const session = Session.create(SessionId('t2'))
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
session.append('todo/write', { todos: [
{ content: 'first', status: 'completed' },
@@ -1626,7 +1673,7 @@ describe('todo/write event', () => {
})
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
const session = new Session(SessionId('t3'))
const session = Session.create(SessionId('t3'))
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -1639,14 +1686,15 @@ describe('todo/write event', () => {
})
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
const original = new Session(SessionId('t4'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const original = Session.create(SessionId('t4'))
original.append('turn/start', { turn: 1 })
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Seeding a non-surface event with no surfaceOp must not throw.
const replayed = new Session(SessionId('t4-replay'), [...original.events])
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
.toEqual([{ content: 'only', status: 'completed' }])
expect(replayed.seq).toBe(original.seq)
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.firstLiveSeq).toBe(original.seq)
})
})

View File

@@ -4,9 +4,12 @@ import {
Session,
SessionId,
foldSurface,
isAppendSurfaceEvent,
isReplacementSurfaceEvent,
isSurfaceEligibleType,
isSurfaceEvent,
} from '@deepseek-ai/dsh-session'
import { SurfaceManager } from '@deepseek-ai/dsh-session/surface'
import {
createMessage,
createToolResultMessage,
@@ -18,8 +21,8 @@ import {
/** Build a minimal session with turn boundaries and a single user message. */
function surfaceSession(): Session {
const s = new Session(SessionId('ss'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const s = Session.create(SessionId('ss'))
s.append('turn/start', { turn: 1 })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -93,7 +96,7 @@ describe('foldSurface provenance', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
sourceEventSeqs: [0],
} as unknown as SessionEvent
expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
@@ -237,8 +240,55 @@ describe('foldSurface tool-result rewrites', () => {
})
describe('SurfaceManager', () => {
it('folds a contiguous window without materializing earlier event sequences', () => {
const baseSeq = 400_000
const events = [
provenanceEvent(baseSeq, undefined),
provenanceEvent(baseSeq + 1, undefined),
{
...provenanceEvent(baseSeq + 2, [baseSeq]),
surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq },
},
] as SessionEvent[]
const surface = new SurfaceManager(events, baseSeq)
expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1])
expect(surface.replaceGeneration).toBe(1)
})
it('validates tool-result rewrites against a nonzero window offset', () => {
const baseSeq = 400_000
const original = toolResultEvent(baseSeq, 'call')
const events: SessionEvent[] = [
original,
{
...original,
seq: baseSeq + 1,
time: baseSeq + 1,
surfaceOp: { op: 'replace' as const, start: baseSeq, end: baseSeq },
sourceEventSeqs: [baseSeq],
} as SessionEvent,
]
expect(new SurfaceManager(events, baseSeq).nodes).toEqual([baseSeq + 1])
})
it('rejects a replacement that crosses a loaded window head', () => {
const baseSeq = 400_000
const events = [
provenanceEvent(baseSeq, undefined),
{
...provenanceEvent(baseSeq + 1, [baseSeq - 1, baseSeq]),
surfaceOp: { op: 'replace', start: baseSeq - 1, end: baseSeq },
},
] as SessionEvent[]
expect(() => new SurfaceManager(events, baseSeq).nodes)
.toThrow(`surface replace: start seq ${baseSeq - 1} not found in surface`)
})
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))
const s = Session.create(SessionId('shared-fold'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -282,7 +332,7 @@ describe('SurfaceManager', () => {
})
it('does not retain fold-only replacement history in incremental state', () => {
const s = new Session(SessionId('incremental-state'))
const s = Session.create(SessionId('incremental-state'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -313,12 +363,12 @@ describe('SurfaceManager', () => {
] as SessionEvent[]
expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
expect(() => new Session(SessionId('shared-fold-invalid'), events))
expect(() => Session.create(SessionId('shared-fold-invalid'), events))
.toThrow(/start seq 42 not found/)
})
it('leaves incremental state unchanged when candidate validation fails', () => {
const s = new Session(SessionId('atomic-validation'))
const s = Session.create(SessionId('atomic-validation'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -378,7 +428,7 @@ describe('SurfaceManager', () => {
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
data: { turn: 1 },
surfaceOp: 'append',
} as unknown as SessionEvent
@@ -395,8 +445,8 @@ describe('SurfaceManager', () => {
})
it('empty surface yields empty nodes', () => {
const s = new Session(SessionId('empty'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const s = Session.create(SessionId('empty'))
s.append('turn/start', { turn: 1 })
s.append('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -429,7 +479,7 @@ describe('SurfaceManager', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
const replayed = new Session(SessionId('replay'), [...original.events])
const replayed = Session.create(SessionId('replay'), [...original.events])
expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
})
@@ -454,7 +504,7 @@ describe('SurfaceManager', () => {
})
it('replace with both ends at real nodes splices only the range', () => {
const s = new Session(SessionId('range'))
const s = Session.create(SessionId('range'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -483,7 +533,7 @@ describe('SurfaceManager', () => {
})
it('single-node replacement (start === end)', () => {
const s = new Session(SessionId('single'))
const s = Session.create(SessionId('single'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -509,7 +559,7 @@ describe('SurfaceManager', () => {
})
it('throws when replace start is not found', () => {
const s = new Session(SessionId('bad-start'))
const s = Session.create(SessionId('bad-start'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -530,7 +580,7 @@ describe('SurfaceManager', () => {
})
it('throws when replace end is not found', () => {
const s = new Session(SessionId('bad-end'))
const s = Session.create(SessionId('bad-end'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -551,7 +601,7 @@ describe('SurfaceManager', () => {
})
it('throws when start is after end', () => {
const s = new Session(SessionId('reversed'))
const s = Session.create(SessionId('reversed'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -576,7 +626,7 @@ describe('SurfaceManager', () => {
})
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
const s = new Session(SessionId('immutable'))
const s = Session.create(SessionId('immutable'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -600,7 +650,7 @@ describe('SurfaceManager', () => {
})
it('replace starting at non-head position preserves surrounding order', () => {
const s = new Session(SessionId('mid-replace'))
const s = Session.create(SessionId('mid-replace'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
@@ -629,7 +679,7 @@ describe('SurfaceManager', () => {
})
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
const s = new Session(SessionId('immutable-op'))
const s = Session.create(SessionId('immutable-op'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -664,8 +714,8 @@ describe('deriveMessages with surface', () => {
})
it('surface path skips non-surface events (chunks, boundaries)', () => {
const s = new Session(SessionId('filter'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const s = Session.create(SessionId('filter'))
s.append('turn/start', { turn: 1 })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
s.append('user/message', createUserMessage({
@@ -688,7 +738,7 @@ describe('deriveMessages with surface', () => {
})
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
const s = new Session(SessionId('compacted'))
const s = Session.create(SessionId('compacted'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
@@ -709,18 +759,15 @@ describe('deriveMessages with surface', () => {
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
})
it('injected-context and steering/message appear on surface', () => {
const s = new Session(SessionId('ctx'))
it('injected-context and user messages appear on surface', () => {
const s = Session.create(SessionId('ctx'))
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
}), { surfaceOp: 'append' })
s.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'focus' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'focus' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
@@ -730,8 +777,8 @@ describe('deriveMessages with surface', () => {
describe('Session.append surface opts', () => {
it('records sourceEventSeqs and surfaceOp on the event', () => {
const s = new Session(SessionId('opts'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const s = Session.create(SessionId('opts'))
s.append('turn/start', { turn: 1 })
s.append('step/start', { turn: 1, step: 1 })
const event = s.append('assistant/message',
{
@@ -759,7 +806,7 @@ describe('Session.append surface opts', () => {
// but _deriveOneMessage returns null for it, so the surface derivation path's
// null-check is exercised — the node is on the surface yet produces no message.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: {
turn: 1, step: 1,
@@ -775,20 +822,20 @@ describe('Session.append surface opts', () => {
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
const s = new Session(SessionId('nomessage'), seed)
const s = Session.create(SessionId('nomessage'), seed)
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
expect(s.deriveMessages()).toHaveLength(0)
})
it('a non-surface event carries no surface fields', () => {
const s = new Session(SessionId('noopts'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const s = Session.create(SessionId('noopts'))
s.append('turn/start', { turn: 1 })
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
})
it('surfaceOp primitives are not cloned (they are immutable)', () => {
const s = new Session(SessionId('prim'))
const s = Session.create(SessionId('prim'))
const event = s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
@@ -816,7 +863,7 @@ describe('Session.append surface opts', () => {
}
expect(isSurfaceEvent(noMarker)).toBe(false)
// A non-surface type is rejected too (the type gate).
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } }
expect(isSurfaceEvent(boundary)).toBe(false)
// A properly-marked surface event narrows.
const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
@@ -829,7 +876,6 @@ describe('surface type guards', () => {
expect(isSurfaceEligibleType('user/message')).toBe(true)
expect(isSurfaceEligibleType('assistant/message')).toBe(true)
expect(isSurfaceEligibleType('tool/result')).toBe(true)
expect(isSurfaceEligibleType('steering/message')).toBe(true)
expect(isSurfaceEligibleType('turn/start')).toBe(false)
expect(isSurfaceEligibleType('assistant/chunk')).toBe(false)
})
@@ -861,12 +907,46 @@ describe('surface type guards', () => {
expect(isSurfaceEligibleType(markerless.type)).toBe(true)
expect(isSurfaceEvent(markerless)).toBe(false)
})
it('splits surface events into append-origin and replacement by their marker', () => {
const s = surfaceSession()
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
}), { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
const appended = s.events.find(e => e.type === 'user/message')!
const replacement = s.events.at(-1)!
expect(isAppendSurfaceEvent(appended)).toBe(true)
expect(isReplacementSurfaceEvent(appended)).toBe(false)
expect(isAppendSurfaceEvent(replacement)).toBe(false)
expect(isReplacementSurfaceEvent(replacement)).toBe(true)
})
it('rejects log-only and markerless events from both marker guards', () => {
const s = surfaceSession()
const turnStart = s.events.find(e => e.type === 'turn/start')!
// A surface-eligible type whose mandatory marker is absent has no origin at
// all: it never entered the surface.
const markerless: SessionEvent = {
type: 'user/message',
seq: 0,
time: 0,
data: createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}),
}
expect(isAppendSurfaceEvent(turnStart)).toBe(false)
expect(isReplacementSurfaceEvent(turnStart)).toBe(false)
expect(isAppendSurfaceEvent(markerless)).toBe(false)
expect(isReplacementSurfaceEvent(markerless)).toBe(false)
})
})
describe('SurfaceManager.replaceGeneration', () => {
it('folds the pending log delta on access and counts replaces', () => {
const s = new Session(SessionId('gen'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const s = Session.create(SessionId('gen'))
s.append('turn/start', { turn: 1 })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })