diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 44272f7ca1..3bbc91fa87 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md -2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff +2026-06-11-dev-invariants-over-deep-readonly.md: 91d9ba2e459a02dc65b95a179d11e2f14af7e28d +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 750aee7ec543979f88a6bb606c00bd789de45c51 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 66980f1ee0..91d9ba2e45 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references. -The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. +The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered. @@ -48,12 +48,12 @@ Freezing history only when an invariants plugin is installed would make the core ### Clone only when deriving messages -Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute. +Detaching `deriveMessages()` would protect the most common request path but leave other readers of `snapshotEvents()`, `eventAt()`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute. ## Consequences - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it. -- `session.events` exposes stable immutable snapshots instead of the private growing array. +- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array. - Request-side mutation cannot reach stored history through derived messages. - Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability. - `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package. diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 576e53e0b2..750aee7ec5 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 -被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。 +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 @@ -48,12 +48,12 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 仅在派生消息时克隆 -分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。 +分离 `deriveMessages()` 能保护最常见的请求路径,但 `snapshotEvents()`、`eventAt()` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。 ## 后果 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 -- `session.events` 暴露稳定的不可变快照,而非持续增长的私有数组。 +- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径。 - 请求侧的修改无法通过派生消息触及已存储的历史。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 05c4325e05..cc913ae925 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: b4566d70c79607bbf736ee02e3e37a79c2391232 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1d1acf00fa6a1efc868c3613715a5ff781e0323a +2026-07-25-web-client-session-scope-and-provide-channel.md: feffb1ac0c5bb91c33e61ea18583202e96bad34d +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5d368b4fd810264cb1b451d583de51e1d4cb232b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index b4566d70c7..feffb1ac0c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -61,7 +61,7 @@ Session instances share the scope's lifecycle; liveness eligibility = host-liste A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended Session never enters `persistence.list()`, so blank never touches disk. +- The host criterion: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended session never enters `persistence.list()`, so blank never touches disk. - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 1d1acf00fa..5d368b4fd8 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -61,7 +61,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended Session 不进入 `persistence.list()`,所以 blank 从不落盘。 +- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended 会话不进入 `persistence.list()`,所以 blank 从不落盘。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml index de962f5749..58dcf1ee20 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md -2026-07-26-packed-chunk-rows-by-default.md: 14da6b3cbe650e80118e7c960c96bf618acd1e48 -2026-07-26-packed-chunk-rows-by-default.zh.md: f62a8e52a67adc960ac3150552594b4f061e6205 +2026-07-26-packed-chunk-rows-by-default.md: bd4b3b9f773afbf6aa7e88d51b6e842d6634c222 +2026-07-26-packed-chunk-rows-by-default.zh.md: eafe6632150aadd74395f4d0f09d064fb703a03d diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md index 14da6b3cbe..bd4b3b9f77 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md @@ -18,7 +18,7 @@ Reading is unconditional and layout-blind. Packed, unpacked, and mixed files loa ### Logical events and physical rows -The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics. +The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters the Session log or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics. The JSONL backend packs each durable append batch. Raw `compression: 'none'` and default Zstandard framing carry the same logical storage records; selecting raw mode for reviewable fixtures does not disable packing. Repository replay readers and normalizers decode the shared row format instead of maintaining snapshot-specific codecs. diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md index f62a8e52a6..eafe663215 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md @@ -18,7 +18,7 @@ JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封 ### 逻辑事件与物理行 -JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。 +JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 Session 日志,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。 JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none'` 与默认 Zstandard 帧承载相同的逻辑存储记录;为使 fixture 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml index 6f00f147f3..18313964ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md -2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547 -2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 2cd0d2ca20074d6adb0735db08071638ae2ced88 +2026-08-05-large-session-jsonl-restore-pipeline.md: 309d9dc6bdb5c3160f3e6e76a8318915df58fe59 +2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 28acd3ebe804dca22a0619c257ff3ad9c09500a9 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md index eab53c6838..309d9dc6bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md @@ -41,7 +41,7 @@ Borrowed seeds used by ordinary creation and fork paths still take a JSON snapsh - **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix. - **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics. - **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal. -- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. +- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and Session read methods promise immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md index 2cd0d2ca20..28acd3ebe8 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md @@ -41,7 +41,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解 - **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。 - **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。 - **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。 -- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 +- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 Session 读取方法承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml new file mode 100644 index 0000000000..c002ef3859 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md +2026-08-21-session-log-read-intent.md: 81cdd770886b24588052d9860e42e4a07650fb03 +2026-08-21-session-log-read-intent.zh.md: e5d0425981e6425323260661bee42281b1e96c98 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md new file mode 100644 index 0000000000..81cdd77088 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md @@ -0,0 +1,32 @@ +# Agent Note: Session log reads state their materialization cost + +Status: implemented + +English | [中文](2026-08-21-session-log-read-intent.zh.md) + +## Problem + +An all-purpose `Session.events` accessor hides an array-sized copy behind every read after an append. The full frozen snapshot can be cached, but streaming invalidates that cache for every new event, so a caller that only needs the log length or one event can repeatedly copy millions of references. Making the return value immutable does not require every read intent to pay that cost; event immutability is owned separately by the [source-owned session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md). + +## Decision + +`Session` exposes three cost-specific read operations. `seq` reads the current length in constant time, `eventAt(seq)` reads one event in constant time, and `snapshotEvents(fromSeq?, toSeqExclusive?)` explicitly materializes a frozen array for consumers that need array operations or a stable materialized range. Sequence parameters are non-negative log positions, not `Array.prototype.slice` offsets from the end. Recurring domain state such as the selected agent preset is read from a Session projection instead of rescanning live history. + +The complete current snapshot is cached until append because repeated whole-log consumers can share the same immutable array. A range snapshot copies only the selected references and is not cached: arbitrary range caching would retain unbounded arrays and require an eviction policy. Previously returned snapshots remain stable because accepted events are immutable and a snapshot array never grows after append. + +Recurring domain-state reads use [session projections](2026-08-19-session-projection-state-and-client-views.md) when the required value can be maintained incrementally. Raw-log snapshots remain appropriate for persistence, export, replay, and consumers whose output is the event sequence itself. This API makes materialization visible but does not attempt to eliminate every full-log fold in the same change. + +## Alternatives considered + +**Keep a cached `events` array accessor.** This preserves ordinary array syntax but makes scalar and indexed reads appear cheap while an append can turn either into a whole-log copy. + +**Return a custom immutable cut with array-like traversal operations.** A captured length could provide a stable constant-time cut over the growing log, but the abstraction would reimplement selected array semantics and keep expanding as callers request more operations. Explicit indexed reads, explicit materialization, and projections cover the shipped intents with a smaller public API. + +**Cache every materialized range or maintain an incremental chunked snapshot.** Range caching needs retention and eviction rules, while a chunked public representation changes consumers and serialization for a cost that many callers avoid through indexed reads or projections. These representations remain options if measured full-snapshot consumers justify them. + +## Consequences + +- Length and single-event reads do not copy the log. +- Full and ranged snapshots retain an explicit linear cost proportional to the selected event count. +- Consumers choose between raw history and incrementally maintained state at the call site. +- The public API does not emulate an array; callers materialize only when they need array operations. diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md new file mode 100644 index 0000000000..e5d0425981 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 会话日志读取显式表达物化成本 + +Status: implemented + +[English](2026-08-21-session-log-read-intent.md) | 中文 + +## 问题 + +通用的 `Session.events` 访问器会在每次 append 后的首次读取中隐藏一次与数组大小成正比的复制。完整冻结快照可以缓存,但流式输出的每个新事件都会使缓存失效,因此只需要日志长度或单个事件的调用方也可能反复复制数百万个引用。返回值不可变并不要求每种读取意图都承担该成本;事件不可变性由[源端拥有的会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)另行负责。 + +## 决策 + +`Session` 提供三种成本不同的读取操作。`seq` 以常数时间读取当前长度,`eventAt(seq)` 以常数时间读取一个事件,`snapshotEvents(fromSeq?, toSeqExclusive?)` 则为需要数组操作或稳定物化区间的消费方显式物化冻结数组。序列参数是非负日志位置,不是从末尾计算的 `Array.prototype.slice` 偏移量。所选 agent preset 这类重复读取的领域状态来自会话投影,而不是反复扫描活跃日志。 + +当前完整快照会缓存到下一次 append,使重复读取整个日志的消费方可以共享同一个不可变数组。区间快照只复制所选引用且不缓存:缓存任意区间会保留数量无界的数组,并要求额外的淘汰策略。先前返回的快照保持稳定,因为已接受的事件不可变,且快照数组不会在 append 后增长。 + +当所需值可以增量维护时,重复的领域状态读取使用[会话投影](2026-08-19-session-projection-state-and-client-views.zh.md)。持久化、导出、回放,以及输出本身就是事件序列的消费方仍适合使用原始日志快照。此 API 使物化成本显式可见,但不会在同一项改动中消除所有完整日志折叠。 + +## 曾考虑的替代方案 + +**保留缓存的 `events` 数组访问器。** 这保留了普通数组语法,但会让标量读取和索引读取看似廉价,而一次 append 就可能使其中任何一次读取变成完整日志复制。 + +**返回提供类数组遍历操作的自定义不可变 cut。** 捕获长度可以在持续增长的日志上提供稳定且常数时间的 cut,但随着调用方要求更多操作,该抽象会不断重新实现所选数组语义。显式索引读取、显式物化和投影以更小的公开 API 覆盖了已经交付的读取意图。 + +**缓存每个物化区间或维护增量分片快照。** 区间缓存需要保留与淘汰规则,而公开的分片表示会改变消费方和序列化方式;许多调用方已通过索引读取或投影避开这项成本。若实测的完整快照消费方证明有必要,仍可考虑这些表示。 + +## 后果 + +- 长度读取和单事件读取不会复制日志。 +- 完整快照和区间快照仍有与所选事件数量成正比的显式线性成本。 +- 消费方在调用点选择原始历史或增量维护的状态。 +- 公开 API 不模拟数组;调用方只在需要数组操作时物化。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml new file mode 100644 index 0000000000..d8166a7a02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md +2026-08-31-viewport-activated-syntax-highlighting.md: 050c4e50240f9e1d1eb4f2708df6bc93c3a1faf1 +2026-08-31-viewport-activated-syntax-highlighting.zh.md: abd02161ae00d8bdd31863826565175e1d8fddf9 diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md new file mode 100644 index 0000000000..050c4e5024 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md @@ -0,0 +1,41 @@ +# Agent Note: Viewport-activated syntax highlighting + +Status: implemented + +English | [中文](2026-08-31-viewport-activated-syntax-highlighting.zh.md) + +## Problem + +A long conversation mounts code fences and read cards far outside the visible viewport. Eager highlighting tokenizes every supported block and creates every token span during that mount, so main-thread work and DOM size scale with the whole rendered history rather than the code the reader can see. The [Shiki selection](../process/2026-07-26-web-syntax-highlighting-shiki.md) and [incremental streaming highlighter](../feature/2026-08-20-web-streaming-fence-highlight.md) bound initialization and repeated prefix work, but neither avoids the first per-block tokenization for unseen history. + +## Decision + +`useViewportHighlighting` owns one shared `IntersectionObserver` for syntax-highlightable `CodeBlock` and `ReadBlock` instances. A supported block renders its existing plain-text arm until its root first intersects the viewport. An absent or unsupported language never registers with the observer. A browser without `IntersectionObserver` activates highlighting after mount so the capability still works. + +The first intersecting entry removes its target from the observer and activates that component for the rest of its lifetime. Leaving the viewport never returns it to plain text. This one-way transition avoids repeated tokenization, token-DOM construction, and visual churn while scrolling. The shared observer disconnects when no inactive registered blocks remain. + +`CodeBlock` gates both settled `highlightToHtml` calls and streaming `StreamingHighlightSession` creation. It starts an activated stream from the current accumulated source, then retains the existing incremental tokenizer and React line caches. `ReadBlock` gates `highlightLines` while retaining its line rows and gutter. The plain and highlighted arms keep the same source text, code font, padding, wrapping, and line height; Shiki's color, bold, italic, and underline token styles remain unchanged. + +The module-level Shiki singleton warm-up remains eager. Viewport activation defers code-block content tokenization and token-span construction, not the fixed boot-grammar warm-up or the plain content DOM. + +## Testing + +The focused jsdom test replaces the process-global `IntersectionObserver`, mounts several code surfaces, and proves that non-intersecting and unsupported blocks remain plain, intersecting blocks share one observer, leaving the viewport does not remove highlighting, and an activated block continues to highlight changed source. It also covers read-card activation and observer disposal. Each test restores the global and unmounts every component, so the module-level registry cannot leak registrations into another case. + +Existing component tests run without `IntersectionObserver` and therefore cover the immediate fallback together with the established Shiki output, font styles, streaming caches, and plain-language behavior. Browser geometry is not measured by this unit suite; geometry stability relies on the unchanged shared typography and box styles of the plain and highlighted arms. + +## Alternatives considered + +**Deactivate highlighting when a block leaves the viewport.** This can reclaim token DOM from blocks already viewed, but scrolling repeatedly rebuilds the same token tree, discards streaming caches, and changes visible presentation at both viewport edges. One-way activation pays the cost at most once per mounted block. + +**Drop bold and italic token styles to make every token use identical font metrics.** This weakens syntax presentation, especially for highlighted Markdown, and is unnecessary for the chosen lifecycle: both render arms already use the same code font and fixed line height. Shiki's existing token styles remain intact. + +**Pin a measured pixel height during activation.** A fixed measurement becomes stale when a streaming fence grows or responsive wrapping changes, and can clip content or introduce an inner vertical scrollbar. The plain arm stays in normal flow instead of adding measurement state. + +**Virtualize complete code blocks or retain only a token window.** This can also bound DOM after a reader has visited every block, but it changes selection, copy, scroll anchoring, and streaming-cache ownership. Viewport activation removes unseen work without changing those behaviors. + +## Consequences + +Supported code that is never viewed incurs no content tokenization and creates no token spans. The first viewport intersection pays the normal synchronous highlight cost; a lazily imported grammar may keep the block plain until its existing load notification arrives. Activated blocks retain their highlighted DOM when scrolled away, so memory use grows with blocks the reader has visited rather than shrinking with the current viewport. + +The optimization is local to presentation. Markdown parsing, Shiki grammar selection and styling, stream-tail tokenization, copy text, and settled output remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md new file mode 100644 index 0000000000..abd02161ae --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 视口激活的语法高亮 + +Status: implemented + +[English](2026-08-31-viewport-activated-syntax-highlighting.md) | 中文 + +## 问题 + +长对话会挂载远在可见视口之外的代码围栏和读取卡片。预先高亮会在挂载时 tokenize 每个受支持的块并创建全部 token span,使主线程工作量和 DOM 大小随完整渲染历史增长,而不是随读者能看到的代码增长。[Shiki 选型](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md)与[流式增量高亮](../feature/2026-08-20-web-streaming-fence-highlight.zh.md)分别约束初始化成本和重复处理前缀的成本,但都无法避免不可见历史首次发生的逐块 tokenize。 + +## 决策 + +`useViewportHighlighting` 为可进行语法高亮的 `CodeBlock` 和 `ReadBlock` 实例持有一个共享的 `IntersectionObserver`。受支持的块在根元素首次与视口相交之前渲染既有的纯文本臂。语言缺失或不受支持时不会向 observer 注册。浏览器不提供 `IntersectionObserver` 时会在挂载后激活高亮,使该能力仍然可用。 + +首个相交条目会从 observer 中移除自己的目标,并在该组件余下的生命周期里保持激活。离开视口不会恢复为纯文本。这种单向转换避免滚动时反复 tokenize、创建 token DOM 和发生视觉切换。不存在尚未激活的注册块时,共享 observer 会断开。 + +`CodeBlock` 同时控制定稿态的 `highlightToHtml` 调用和流式 `StreamingHighlightSession` 的创建。流式块激活时从当前累积源码开始,随后保留既有的增量 tokenizer 与 React 行缓存。`ReadBlock` 控制 `highlightLines`,同时保留其行和行号槽。纯文本臂与高亮臂使用相同的源码文本、代码字体、内边距、换行规则和行高;Shiki 现有的 token 颜色、粗体、斜体和下划线样式保持不变。 + +模块级 Shiki 单例仍然预先预热。视口激活延迟的是代码块内容的 tokenize 与 token span 创建,不是固定的启动语法预热或纯文本内容 DOM。 + +## Testing + +聚焦的 jsdom 测试替换进程全局 `IntersectionObserver`,挂载多个代码表面,并证明未相交和不受支持的块保持纯文本、相交块共用一个 observer、离开视口不会移除高亮,且已激活的块会继续高亮变化后的源码。测试还覆盖读取卡片激活和 observer 释放。每个测试都会恢复全局值并卸载全部组件,因此模块级注册表不会把注册项泄漏到其他用例。 + +既有组件测试在没有 `IntersectionObserver` 的环境中运行,因此同时覆盖立即回退路径,以及既有 Shiki 输出、字体样式、流式缓存和纯文本语言行为。该单元测试套件不测量浏览器几何尺寸;几何稳定性依赖纯文本臂与高亮臂不变的共享字体排印和盒模型样式。 + +## 曾考虑的替代方案 + +**块离开视口时停用高亮。** 这可以回收已经看过的块所占用的 token DOM,但滚动会反复重建同一棵 token 树、丢弃流式缓存,并在视口两端改变可见呈现。单向激活使每个已挂载块最多支付一次成本。 + +**移除粗体和斜体 token 样式,使所有 token 使用完全相同的字体度量。** 这会削弱语法呈现,尤其影响高亮后的 Markdown;所选生命周期也不需要这一取舍,因为两个渲染臂已经使用相同的代码字体和固定行高。Shiki 现有 token 样式保持不变。 + +**在激活时锁定测量得到的像素高度。** 当流式围栏增长或响应式换行变化时,固定测量会陈旧,并可能裁切内容或引入内部纵向滚动条。纯文本臂继续处于正常文档流中,不增加测量状态。 + +**虚拟化完整代码块,或仅保留一个 token 窗口。** 这也能在读者访问所有块之后限制 DOM,但会改变文本选择、复制、滚动锚定与流式缓存的归属。视口激活消除不可见工作,同时不改变这些行为。 + +## 后果 + +从未进入视口的受支持代码不会发生内容 tokenize,也不会创建 token span。首次与视口相交时支付普通的同步高亮成本;若语法采用懒加载,代码块可能继续保持纯文本,直到既有的加载通知到达。已激活的块滚出视口后会保留高亮 DOM,因此内存占用随读者访问过的块增长,而不会随当前视口缩减。 + +该优化仅作用于呈现层。Markdown 解析、Shiki 语法选择与样式、流式尾行 tokenize、复制文本和定稿输出均保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 7593352560..d8a34eb622 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: 72cafb5a1a149bcdb73d885d4e4fc4ac01e48cd2 -2026-07-29-human-transcript-append-origin.zh.md: 79fcc56b7b60082ebd2946d70419a659ff5687b7 +2026-07-29-human-transcript-append-origin.md: 00f4cf09139bdba4c0fffcdc476b698d098154bc +2026-07-29-human-transcript-append-origin.zh.md: 631ba71d841033c19a9daa21e646468a0e366966 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 72cafb5a1a..00f4cf0913 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -8,7 +8,7 @@ English | [中文](2026-07-29-human-transcript-append-origin.zh.md) The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message` and `assistant/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only `compaction/summary` event and the replacement that cites it. -Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. +Nothing was lost from the log. `Session.snapshotEvents()` still returned every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 79fcc56b7b..631ba71d84 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -8,7 +8,7 @@ Status: implemented 终端与宿主历史网关都把模型可见的 surface 当作 transcript(文本记录)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message` 和 `assistant/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志 `compaction/summary` 事件与引用它的替换之间。 -日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 +日志本身没有丢失任何内容。`Session.snapshotEvents()` 仍返回每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index 0bf2cfa13f..abaae859ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: daf11b41236943d5fb35e9363c128ce470c7f4e9 -2026-08-05-context-meter-blind-to-compaction.zh.md: 6ea2f8814c4731fdf7c08d8b867cd7086ceccdbf +2026-08-05-context-meter-blind-to-compaction.md: a23e51771c74e350fd3fb04e3a23ab3cdfb6092e +2026-08-05-context-meter-blind-to-compaction.zh.md: 12fd41af0d8c8864a120f26d91148543f74b2e2b diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index daf11b4123..a23e51771c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -27,7 +27,7 @@ This reverses the "the ring, header, and bar length stay provider-exact" half of ## Alternatives considered -**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold. +**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.eventAt(seq)`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold. **Emit a synthetic usage record at the end of compaction.** Would move `pressureTokens` itself, but the only usage compaction holds is the summarization request's own — a different prompt entirely. Recording it as the conversation's prompt size would be a lie in the durable log rather than in one display. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index 6ea2f8814c..12fd41af0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -27,7 +27,7 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 备选方案 -**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。 +**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.eventAt(seq)`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。 **在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index 6b870f06a2..062c3eff4c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md -2026-08-05-context-form-vocabulary.md: 912842a9d59fd49491a53f987d172d64d4c0e101 -2026-08-05-context-form-vocabulary.zh.md: 0d4e1d44e4d22b8bfcb6c73bb015bc3240381bd3 +2026-08-05-context-form-vocabulary.md: be792256fa3375dd2621d647d6d3074b1a6d6b18 +2026-08-05-context-form-vocabulary.zh.md: f648dd1071128b879e8c36e9fba1646b6c70cda1 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index 912842a9d5..be792256fa 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -39,7 +39,7 @@ That move also relocates catalog **identity**: the republish digest now covers t Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared. -The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a persistence seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that Session at the latest, least diagnosable point. +The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.snapshotEvents()`, which on resume or fork is a persistence seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining source data as fields. Opaque is the documented default; the contract assigns these unsupported cases to it. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index 0d4e1d44e4..f648dd1071 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -39,7 +39,7 @@ Status: implemented 两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。 -生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自持久化 seed,seed 验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该 Session 此后每一步都在最晚、最难定位的点失败。 +生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.snapshotEvents()` 读 `source.entries`,而恢复或 fork 时它来自持久化 seed,seed 验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源数据列成字段。opaque 是文档规定的默认;约定要求这些不支持的情况使用它。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index ad9994bc34..72e8ad9a15 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: ce07e63681a659fb9bf758a24301c531f3f27e39 -2026-08-05-feedback-gated-session-telemetry.zh.md: 9657c7dc9243377233f20b265569829b06162fb5 +2026-08-05-feedback-gated-session-telemetry.md: 9dab135029bd4a5dd209157b48b6f7dca4040224 +2026-08-05-feedback-gated-session-telemetry.zh.md: 824aa1a04f3854163fdd16f4db7a7593540976e0 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index ce07e63681..9dab135029 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -18,7 +18,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. -Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when the exact `feedback/record` object is already stored at `session.events[event.seq]`. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. +Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when `session.eventAt(event.seq)` returns that exact `feedback/record` object. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index 9657c7dc92..824aa1a04f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -18,7 +18,7 @@ Status: implemented 通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)说明了按需路径为何使用权威日志而非记录副本。 -模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是该事件与那个 `feedback/record` 对象身份完全相同,且该对象已存储于 `session.events[event.seq]`。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 +模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是 `session.eventAt(event.seq)` 返回完全相同的 `feedback/record` 对象。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 2a8c4bbcb5..697f943349 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: fce0026f6504298d2212ae52ad11aac862fc7e89 -2026-08-24-user-authorized-subagent-model-routes.zh.md: 2cdf42dd3394ea69072dd3cc5bcc2c90fd0b5e48 +2026-08-24-user-authorized-subagent-model-routes.md: ef6f90d2cc1ec79e1381d75fd8cc25c568af3067 +2026-08-24-user-authorized-subagent-model-routes.zh.md: 23519bfc6619718e1cbf98dbad65eccd90353c0a diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index fce0026f65..ef6f90d2cc 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -12,7 +12,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. -A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. +A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a legacy Session without the event remains disabled, including an explicitly empty restored Session. The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 2cdf42dd33..23519bfc66 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -12,7 +12,7 @@ Status: implemented Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 -设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 +设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而没有该事件的旧 Session 仍保持禁用,包括显式为空的恢复 Session。 固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml index 7ce81010a9..fded384582 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-06-recallable-compaction.md -2026-07-06-recallable-compaction.md: 7309297af84a43a29e03feae815ac8c937a9da6a -2026-07-06-recallable-compaction.zh.md: 890493ae7d6a7b9066dd071b2cc8f642e9c84c86 +2026-07-06-recallable-compaction.md: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448 +2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md index 7309297af8..f0cf1b0602 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md @@ -46,7 +46,7 @@ A new package `@deepseek-ai/dsh-tool-recall` (consumer-only, over the `dsh-sessi - `history_read(checkpoint, offset?)` — renders the shadowed span of any checkpoint in the log, including superseded ones, as `User:`/`Assistant:`/`Tool result:` transcript, paginated by a configured budget with a continuation cursor. - `history_search(query, checkpoint?, limit?)` — case-insensitive literal scan over every shadowed span; returns snippets with checkpoint ids and coverage metadata (`scanned`/`matched`/`truncated`). The zero-match hint notes the scan is literal and points at direct `history_read` of a plausible checkpoint. -Both read `exec.agent.session.events` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. +Both read `exec.agent.session.snapshotEvents()` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. ### Cache and cost diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md index 890493ae7d..8e8793f28b 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -46,7 +46,7 @@ Status: proposed - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。 -两个工具都读取 `exec.agent.session.events`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 +两个工具都读取 `exec.agent.session.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 ### 缓存与成本 diff --git a/apps/cli/package.json b/apps/cli/package.json index 5337f92d0a..cecb660f75 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts index 42b87eff8a..f6155102e3 100644 --- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts @@ -67,7 +67,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test await waitForIdle(ctx, agent) // The agent claims success… - const summary = finalText([...agent.session.events]).toLowerCase() + const summary = finalText(agent.session.snapshotEvents()).toLowerCase() expect(summary.length).toBeGreaterThan(0) // …and the world agrees: the test passes when WE run it, and the test diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 03de26d878..7b22be2958 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() // A compaction ran: the start…end bracket landed in the real log. const starts = events.filter(e => e.type === 'compaction/start') diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts index a8825cc84d..cf55e35d28 100644 --- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts @@ -34,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() const calls = events.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThan(0) expect(calls.some(event => event.data.name === 'bash')).toBe(true) diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts index 495b7ebbd2..e09be01d0e 100644 --- a/apps/cli/tests/profiles/headless/tests/harness.ts +++ b/apps/cli/tests/profiles/headless/tests/harness.ts @@ -96,7 +96,7 @@ export function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -export function finalText(events: SessionEvent[]): string { +export function finalText(events: readonly SessionEvent[]): string { const message = events.findLast(event => event.type === 'assistant/message') if (message?.type !== 'assistant/message') return '' return message.data.message.content diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 0f076fd30e..fba29b93b3 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -366,7 +366,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr + 'and return only the joined string.', }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events: SessionEvent[] = [...agent.session.events] + const events: readonly SessionEvent[] = agent.session.snapshotEvents() // The wire contract: every request this session made offered EXACTLY ONE // tool — run_code (the logged header snapshots the assembled list). @@ -418,11 +418,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - const events: SessionEvent[] = [...handle.agent.session.events] + const events: readonly SessionEvent[] = handle.agent.session.snapshotEvents() const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { - const splice = handle.agent.session.events.findLast(event => event.type === 'agent/inbox/spliced' + const splice = handle.agent.session.snapshotEvents().findLast(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'agent-instructions')) const inserted = splice?.type === 'agent/inbox/spliced' ? splice.data.inserted.find(message => message.source.kind === 'agent-instructions') diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts index 6767357b4e..a47c01c4b6 100644 --- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts @@ -63,6 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. - expect(finalText([...resumed.session.events])).toContain(SECRET) + expect(finalText(resumed.session.snapshotEvents())).toContain(SECRET) }, 180_000) }) diff --git a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts index 014f1e3e69..5fa792b672 100644 --- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts @@ -37,7 +37,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a + 'Send all three in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() // The model actually called the tool. const calls = events.filter(event => event.type === 'tool/call') diff --git a/apps/web/package.json b/apps/web/package.json index 9896155714..f993d39587 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 1030369886..284b359eeb 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') - const idle = page.getByRole('button', { name: '1 background job' }) + const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 8e4a5769ef..5e58d868c5 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -330,7 +330,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { } if (sessionId === undefined) throw new Error('continuous conversation completed no turn') - expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => ( + expect(scaffold.ctx.agents.get(sessionId)?.session.snapshotEvents().filter(event => ( event.type === 'turn/end' && event.data.reason.kind === 'completed' ))).toHaveLength(TURN_COUNT) expect(sessionEvents.flatMap(event => diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 55829bd4d5..15d598a58f 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -181,13 +181,13 @@ describe('web e2e: long Chat interaction contract', () => { const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN) const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1) const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2) - const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker) - const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker) + const toolUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', toolUserMarker) + const toolAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', toolAssistantMarker) const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN) const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN) - const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker) - const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker) - const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => ( + const branchUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', branchUserMarker) + const branchAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', branchAssistantMarker) + const boundary = source.session.snapshotEvents().find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`) @@ -312,9 +312,9 @@ describe('web e2e: long Chat interaction contract', () => { .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)) if (child === undefined) throw new Error('message branch did not create a child session') expect(child.session.header.seedLength).toBe(boundary.seq + 1) - expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true) - expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) - expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) + expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true) + expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) + expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' }) .getByRole('button').last() @@ -331,11 +331,11 @@ describe('web e2e: long Chat interaction contract', () => { await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) expect(await composer.textContent()).toBe('') expect(await composer.isEnabled()).toBe(true) - expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false) - expect(child.session.events.filter(event => ( + expect(source.session.snapshotEvents().some(event => carries(event, CONTINUE_PROMPT))).toBe(false) + expect(child.session.snapshotEvents().filter(event => ( event.type === 'user/message' && carries(event, CONTINUE_PROMPT) ))).toHaveLength(1) - const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => ( + const lastTurnEnd = child.session.snapshotEvents().findLast((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' )) expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' }) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index ed6c80da68..5f59aa48e5 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -164,7 +164,7 @@ function fixtureLog(session: Session): string { cwd: '{{cwd}}', delegationDepth: 0, }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index c43b919e5d..564334b1f3 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -313,7 +313,7 @@ function fixtureLog(session: Session): string { } return [ JSON.stringify(header), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 7f0bd5c37f..d3a3823799 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -113,7 +113,7 @@ describe('web e2e: /goal human transcript presentation', () => { const sessions = scaffold.ctx.sessions.list() expect(sessions).toHaveLength(1) - const persisted = sessions[0]?.events ?? [] + const persisted = sessions[0]?.snapshotEvents() ?? [] expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => event.type)).toEqual(['command/run', 'command/done']) expect(persisted.some(event => event.type === 'user/message')).toBe(false) diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts index b99f7f90d4..da82648c50 100644 --- a/apps/web/tests/markdown-cjk-strong.e2e.ts +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -76,7 +76,7 @@ function markdownFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 54783b48a5..a888469339 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -131,7 +131,7 @@ function markdownImageFixture(remoteUrl: string): string { // the stats line renders its LLM segment only while the step's measured // milliseconds exceed zero, so a fixture that leaves the times unset lets // the replay's own speed decide whether the golden matches. - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index be3e30a380..d1da29922a 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -73,7 +73,7 @@ function markdownFixture(linkUrl: string): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 77e583bf45..28e98a89a6 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -139,7 +139,7 @@ function wideTableFixture(): string { return [ JSON.stringify(header), // Spaced event times, as the sibling markdown fixtures pin them. - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index c5e3e22a60..020d4b2a3a 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -77,7 +77,7 @@ function mathFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index b63e155d9d..19f829d5ac 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -72,7 +72,7 @@ describe('minimal agent preset', () => { it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { const requestHeader = agentHandle.agent.session.requestHeader() if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') - expect(agentHandle.agent.session.events.some(event => event.type === 'user/message' + expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false) const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs') diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index 9e1772cdce..ca8fafc63e 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -107,7 +107,7 @@ function mentionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 0761639de5..5494a934e3 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -96,7 +96,7 @@ function producedFixture(): string { type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), '', diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index f8614e9ae7..dc84191180 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -58,7 +58,7 @@ function sourceSessionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } @@ -105,7 +105,7 @@ function targetSessionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1991ce2e0b..c0fe365775 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -803,7 +803,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise JSON.stringify(record)), + ...packChunkRuns(session.snapshotEvents()).map(record => JSON.stringify(record)), '', ].join('\n') } @@ -848,7 +848,7 @@ async function assertReplaySession( const userPrompts = fixtureUserPrompts(expected) const candidates = sessions.filter((session) => { if (session.header.parentSession !== undefined) return false - const actual = session.events.flatMap((event) => { + const actual = session.snapshotEvents().flatMap((event) => { if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') return text.length === 0 ? [] : [text] diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index d0e09bfd14..72b98105aa 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -205,7 +205,7 @@ async function waitForReply( ): Promise> { const deadline = Date.now() + timeoutMs while (true) { - const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => ( + const event = handle.agent.session.snapshotEvents().find((candidate): candidate is SessionEvent<'assistant/message'> => ( candidate.type === 'assistant/message' && assistantText(candidate) === text )) if (event !== undefined) return event @@ -455,7 +455,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every')) const ids = new Set(everyRecords.map(record => record.id)) - const dispatches = everyHandle.agent.session.events.filter(event => ( + const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' && ids.has(event.data.id) @@ -470,7 +470,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const decision = acceptedAt[0] if (decision === undefined) throw new Error('missing Every decision time') - const batch = everyHandle.agent.session.events.find(event => ( + const batch = everyHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'schedule' @@ -493,7 +493,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { if (reminderRequest === undefined) throw new Error('model did not receive the Every batch') expect(requestText(reminderRequest)).toContain(batchBlock.text) expectReminderFraming(reminderRequest) - const active = foldScheduleEvents(everyHandle.agent.session.events).active + const active = foldScheduleEvents(everyHandle.agent.session.snapshotEvents()).active expect(active).toHaveLength(2) expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true) @@ -516,7 +516,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { it('uses request-local browser context to create an explicit local At reminder', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at')) - const user = atHandle.agent.session.events.find(event => ( + const user = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'user' && event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT) @@ -541,12 +541,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { } expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE) - const toolCall = atHandle.agent.session.events.find(event => ( + const toolCall = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'tool/call' && event.data.name === 'schedule_create' )) if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call') expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt }) - const created = atHandle.agent.session.events.find(event => ( + const created = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'schedule/change' && event.data.operation === 'create' && event.data.schedule.kind === 'at' @@ -560,7 +560,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { prompt: AT_PROMPT, scheduledAt, }) - expect(atHandle.agent.session.events.filter(event => ( + expect(atHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' && event.data.id === schedule.id diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 368beeb9e1..a55958353d 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -516,7 +516,7 @@ describe('web e2e: seeded history renders through cold resume', () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - const done = agent.session.events.filter(event => event.type === 'command/done').at(-1) + const done = agent.session.snapshotEvents().filter(event => event.type === 'command/done').at(-1) if (done?.type !== 'command/done') throw new Error('feedback command did not settle') const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index f86f5c0964..f3be2fcd39 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -144,7 +144,7 @@ describe('web e2e: settings modal and General preferences', () => { it('stores Permission as the default for future sessions without changing an existing session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) - expect(existing.events.find(event => event.type === 'permission/preset')?.data) + expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) await page.getByRole('button', { name: '设置', exact: true }).click() @@ -160,11 +160,11 @@ describe('web e2e: settings modal and General preferences', () => { const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('permission:') expect(document).toContain('defaultPreset: read-only') - expect(existing.events.find(event => event.type === 'permission/preset')?.data) + expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) - expect(created.events.map(event => [event.type, event.data])).toEqual([ + expect(created.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'read-only' }], ['sandbox/mode', { mode: 'read-only' }], ['approval/policy', { policy: 'ask' }], @@ -181,7 +181,7 @@ describe('web e2e: settings modal and General preferences', () => { const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(confirmedDocument).toContain('defaultPreset: danger-full-access') const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed')) - expect(confirmed.events.map(event => [event.type, event.data])).toEqual([ + expect(confirmed.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 6f2ca01325..24e642c83a 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -290,7 +290,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const child = scaffold.ctx.agents.get(childId) expect(child).toBeDefined() expect(child!.inbox.nextTurn).toHaveLength(2) - expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) // Only the waking send resumes the parked queue, FIFO, to settlement. diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 082bc0802b..54b8bd1cc6 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -158,8 +158,8 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(child).toBeDefined() expect(child!.status).toBe('idle') expect(child!.inbox.nextTurn).toHaveLength(1) - expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1) + expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + const lastEnd = child!.session.snapshotEvents().filter(event => event.type === 'turn/end').at(-1) expect((lastEnd)?.data.reason.kind).toBe('aborted') // Only an explicit waking send resumes the parked queue, FIFO, then the diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 287f413fab..81c4ae4095 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: a68441448f36b9bdc13793fb6a8d5395765a4366 -config-catalog.zh.md: c590c8e0a930f52b0013d8ce70fdb46a9e58f9d5 +config-catalog.md: aa8077cbe380d333d73412796ebf990d4b5e79d1 +config-catalog.zh.md: e70f409491d07c6fcf0982a8322cbe2c0b5ab844 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a68441448f..aa8077cbe3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3150,7 +3150,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c590c8e0a9..e70f409491 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3152,7 +3152,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 9016b73278..c5520d4dd2 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: a15b796e78de525874f8c28867e6748ba8779db8 -persistence-catalog.zh.md: f98485343e73b1bc777f6517a05cade49c77a15f +persistence-catalog.md: fd5b3a4a377069fb7b1fbc37b78d11ec037073a4 +persistence-catalog.zh.md: 9d8fc88a44da1620e2de9aa066b9d6c6204b29b1 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a15b796e78..fd5b3a4a37 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -189,7 +189,7 @@ Source: [`packages/interaction/user-approval/src/types.ts:55`](../packages/inter * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index f98485343e..9d8fc88a44 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -191,7 +191,7 @@ export type SessionEvent = { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 8ad0ddf475..6998ba0cbe 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: d35395fb80a6558e15b82cdb76559f59e492f425 -session.zh.md: d3bc750b5c198e38efa94c88f95dcb9406c9059b +session.md: fbeb501b9ba6283a41c108fc07fc8787ed9e68a4 +session.zh.md: daf25d74bc5bd039a7d8b1a1882c2ec197136ce3 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index d35395fb80..fbeb501b9b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -388,7 +388,7 @@ declare class Session { * 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; /** * Create a detached session by validating and snapshotting borrowed seed @@ -410,12 +410,20 @@ declare class Session { */ static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[]; + eventAt(seq: number): SessionEvent | undefined; + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[]; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ get seq(): number; /** @@ -462,7 +470,7 @@ declare class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -584,7 +592,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h ## Durability contract -What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). @@ -622,7 +630,7 @@ resolveAgent(sessionId: SessionId): Promise * @param signal - optional caller cancellation for persistence reads. * @returns the current attached state or persisted header and event prefix. */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> /** * Read all visible Session rows without resuming an Agent. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d3bc750b5c..daf25d74bc 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -390,7 +390,7 @@ declare class Session { * 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; /** * Create a detached session by validating and snapshotting borrowed seed @@ -412,12 +412,20 @@ declare class Session { */ static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[]; + eventAt(seq: number): SessionEvent | undefined; + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[]; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ get seq(): number; /** @@ -464,7 +472,7 @@ declare class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -588,7 +596,7 @@ interface TurnEndReasonMap { ## 持久性约定 -持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 +持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 消费此约定的后端见 [persistence.md](persistence.zh.md)。 @@ -626,7 +634,7 @@ resolveAgent(sessionId: SessionId): Promise * @param signal - optional caller cancellation for persistence reads. * @returns the current attached state or persisted header and event prefix. */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> /** * Read all visible Session rows without resuming an Agent. diff --git a/package.json b/package.json index 768d491cb1..d947add909 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index b838e5cd68..3a63dcaadc 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 99cd3695fc..25fc940a06 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -68,10 +68,13 @@ describe('ACP machine permission policy', () => { it('delegates a same-id foreign agent', async () => { harness = await makeBridgeHarness() const request = await ownedRequest() + const events = [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }] const foreign = { session: { id: request.agent.session.id, - events: [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], + seq: events.length, + eventAt: (seq: number) => events[seq], + snapshotEvents: () => events, append: () => ({}), }, } as unknown as Agent diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 85125b3661..55e56a25b0 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -882,7 +882,7 @@ describe('automation-only ACP bridge', () => { expect(secondImage.attachment.mediaType).toBe('image/jpeg') expect(secondImage.attachment.bytes).toBe(1) const agent = harness.ctx.agents.get(SessionId(sessionId)) - expect(JSON.stringify(agent?.session.events)).not.toContain('AQ==') + expect(JSON.stringify(agent?.session.snapshotEvents())).not.toContain('AQ==') }) it('rejects a malformed image batch atomically and frees the prompt slot', async () => { @@ -953,7 +953,7 @@ describe('automation-only ACP bridge', () => { sessionId, prompt: [{ type: 'image', data: '', mimeType: 'image/png' }], })).rejects.toThrow(/inline image prompts were not advertised/) - expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) it('renders baseline resource links as textual references in the user message', async () => { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 8a44856590..c44ee3ab2e 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -216,7 +216,7 @@ describe('ACP prompt lifecycle', () => { const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) .finally(() => { settled = true }) await vi.waitFor(() => { - expect(agent.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(agent.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.length > 0)).toHaveLength(2) }) expect(settled).toBe(false) @@ -316,7 +316,7 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) expect(harness.adapter.requests).toEqual([]) - const events = harness.ctx.agents.get(SessionId(sessionId))?.session.events ?? [] + const events = harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents() ?? [] expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) }) @@ -443,7 +443,7 @@ describe('ACP prompt lifecycle', () => { await harness.client.cancel({ sessionId }) await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) await agent.whenIdle() - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -468,13 +468,13 @@ describe('ACP prompt lifecycle', () => { source: { kind: 'plugin', plugin: 'test' }, })) await vi.waitFor(() => { - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) await harness.client.cancel({ sessionId }) await agent.whenIdle() - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 9bafea1179..8a7c0eae65 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 9f0d5d4cab..3a36e97ecd 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -19,11 +19,13 @@ export type RemoteStreamOpener = ( /** Convert an invocation or carrier failure to a stable wire value. */ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure +const MAX_MISSED_HEARTBEATS = 2 + /** Own the no-server WebSocket acceptor and every active logical stream. */ export class RemoteStreamMuxServer { private readonly server = new WebSocketServer({ noServer: true }) private readonly connections = new Set>() - private readonly heartbeatAlive = new WeakMap() + private readonly missedHeartbeats = new WeakMap() private heartbeatTimer: NodeJS.Timeout | undefined /** @@ -45,8 +47,8 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { this.server.handleUpgrade(req, socket, head, (websocket) => { - this.heartbeatAlive.set(websocket, true) - websocket.on('pong', () => { this.heartbeatAlive.set(websocket, true) }) + this.missedHeartbeats.set(websocket, 0) + websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) this.startHeartbeat() const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) const done = connection.run() @@ -75,11 +77,16 @@ export class RemoteStreamMuxServer { this.heartbeatTimer = setInterval(() => { for (const socket of this.server.clients) { if (socket.readyState !== WebSocket.OPEN) continue - if (this.heartbeatAlive.get(socket) === false) { - socket.terminate() + const missed = this.missedHeartbeats.get(socket) as number + if (missed >= MAX_MISSED_HEARTBEATS) { + setImmediate(() => { + if ((this.missedHeartbeats.get(socket) as number) >= MAX_MISSED_HEARTBEATS) { + socket.terminate() + } + }) continue } - this.heartbeatAlive.set(socket, false) + this.missedHeartbeats.set(socket, missed + 1) socket.ping() } }, this.heartbeatIntervalMs) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index e73c76f8c0..6c2cc1da30 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -50,7 +50,7 @@ describe('Remote stream mux server carrier lifecycle', () => { await closed }) - it('terminates a socket that does not answer the previous heartbeat', async () => { + it('requires two missed heartbeats before terminating an unresponsive socket', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) @@ -58,10 +58,39 @@ describe('Remote stream mux server carrier lifecycle', () => { const terminated = vi.spyOn(serverSocket, 'terminate') const closed = once(client, 'close') + await once(client, 'ping') + await once(client, 'ping') + expect(terminated).not.toHaveBeenCalled() await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) await closed }) + it('keeps the socket when a delayed Pong arrives before the final check', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url, false) + const serverSocket = acceptedSocket(entry.mux) + const terminated = vi.spyOn(serverSocket, 'terminate') + let finalCheck: (() => void) | undefined + const immediate = vi.spyOn(globalThis, 'setImmediate').mockImplementation((callback) => { + finalCheck = callback + return 0 as unknown as NodeJS.Immediate + }) + + try { + await once(client, 'ping') + await once(client, 'ping') + await vi.waitFor(() => { expect(finalCheck).toBeDefined() }) + serverSocket.emit('pong', Buffer.alloc(0)) + finalCheck?.() + expect(terminated).not.toHaveBeenCalled() + } finally { + immediate.mockRestore() + const closed = once(client, 'close') + client.close() + await closed + } + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -223,8 +252,8 @@ async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): return entry } -async function connect(url: string): Promise { - const socket = new WebSocket(url) +async function connect(url: string, autoPong = true): Promise { + const socket = new WebSocket(url, { autoPong }) await once(socket, 'open') return socket } diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a4334e0e75..35b163fe48 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index fd95221825..352178c781 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -197,8 +197,8 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { scopedResult: scopedResult.value, rootGoal: host.goals.get(rootAgent)?.objective, scopedGoal: host.goals.get(scopedAgent)?.objective, - rootEvents: rootAgent.session.events.length, - scopedEvents: scopedAgent.session.events.length, + rootEvents: rootAgent.session.snapshotEvents().length, + scopedEvents: scopedAgent.session.snapshotEvents().length, } await client.fiber.dispose() diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index cec763e14f..15e25e7d3b 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 0988660659..701aedcc36 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -48,7 +48,7 @@ import type { interface SessionReadState { readonly id: SessionId readonly header: SessionHeader - readonly events: SessionEvent[] + readonly events: readonly SessionEvent[] } /** Implements Session business commands delegated by the Session Controller Remote service. */ @@ -473,7 +473,7 @@ export class SessionCommandController { private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return { id: attached.id, header: attached.header, events: [...attached.events] } + return { id: attached.id, header: attached.header, events: attached.snapshotEvents() } } const inspected = await inspectApiSession(this.ctx, sessionId) return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 106381be88..7264e8cb82 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -113,7 +113,7 @@ export class SessionHistoryController { // Constructor seed events have no session/event notification. Normally // only the end-seed suffix is new; if persistence advanced after the // opening observation, replay everything beyond that snapshot cursor. - const suffix = session.events.slice(snapshotCursor === undefined + const suffix = session.snapshotEvents(snapshotCursor === undefined ? session.firstLiveSeq : snapshotCursor + 1) for (let index = suffix.length - 1; index >= 0; index -= 1) { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 572f895754..4a103dbd22 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -191,10 +191,10 @@ export class SessionController extends TypertRemoteService { inspect( sessionId: SessionId, signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return Promise.resolve({ meta: attached.header, events: [...attached.events] }) + return Promise.resolve({ meta: attached.header, events: attached.snapshotEvents() }) } return inspectApiSession(this.ctx, sessionId, signal) } diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 6bfea1609d..922e0c8e2c 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -377,7 +377,13 @@ describe('ApiSession create or adoption', () => { } as never) const resumed = { id: meta.id, - session: { id: meta.id, header: meta, events }, + session: { + id: meta.id, + header: meta, + snapshotEvents: () => events, + eventAt: (seq: number) => events[seq], + seq: events.length, + }, status: 'idle', ctx, } as unknown as Agent diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index bda4b6c962..20c374e8c3 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -325,7 +325,7 @@ describe('attached updatedAt tracks human prompts', () => { meta: { cwd: '/proj', createdAt: 500 }, }) ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) - const boundary = resumed.events.at(-1) + const boundary = resumed.snapshotEvents().at(-1) expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 86fb18c16f..958e53f5ba 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -92,7 +92,7 @@ describe('sessions.fork', () => { expect(response.ok).toBe(true) if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) - expect(child?.events.map(event => event.type)).toEqual([ + expect(child?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ]) expect(child?.header.parentSession).toBe(source.id) @@ -198,13 +198,13 @@ describe('sessions.fork', () => { const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { - expect(ctx.sessions.get(omitted.value.sessionId)?.events.map(event => event.type)) + expect(ctx.sessions.get(omitted.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 })) expect(pastEnd.ok).toBe(true) if (pastEnd.ok) { - expect(ctx.sessions.get(pastEnd.value.sessionId)?.events.map(event => event.type)) + expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } await ctx.fiber.dispose() @@ -227,11 +227,11 @@ describe('sessions.fork', () => { const source = liveAgent(ctx, 'session-aborted', 1, 'aborted') // What a stopped message's fork button anchors on: the frozen node sits // one event before its turn/end, floored client-side to that event's seq. - const anchor = (source.events.at(-1)?.seq ?? 0) - 1 + const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response.ok).toBe(true) if (!response.ok) return - expect(ctx.sessions.get(response.value.sessionId)?.events.map(event => event.type)).toEqual([ + expect(ctx.sessions.get(response.value.sessionId)?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'turn/start', 'user/message', 'turn/end', 'session/end-seed', @@ -242,7 +242,7 @@ describe('sessions.fork', () => { it('rejects an in-log anchor whose turn is still open', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-open', 1, 'open') - const anchor = source.events.at(-1)?.seq ?? 0 + const anchor = source.snapshotEvents().at(-1)?.seq ?? 0 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ ok: false, diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index d42639922e..90b5851e08 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -146,7 +146,7 @@ describe('Session history raw journal', () => { value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } }, }) - const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => { throw new Error('live result rescanned Session history') }) try { @@ -366,7 +366,7 @@ describe('Session history raw journal', () => { await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'event', event: { type: 'turn/end' } }, }) - const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => { throw new Error('live result rescanned Session history') }) try { diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 0718f7d657..c2ac3216a5 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -318,7 +318,7 @@ describe('session.history projections block', () => { expect('test/last-user' in after.projections.values).toBe(false) expect(after.projections.values.sessionListMetadata).toEqual({ blank: true, - lastPromptAt: session.events.at(-1)?.time, + lastPromptAt: session.eventAt(session.seq - 1)?.time, }) }) @@ -352,7 +352,7 @@ describe('session.list projections column', () => { expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) expect(row?.projections?.values.sessionListMetadata).toEqual({ blank: false, - lastPromptAt: session.events.at(-1)?.time, + lastPromptAt: session.eventAt(session.seq - 1)?.time, }) expect(row?.projections?.asOfSeq).toBe(session.seq - 1) }) diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index 08d2056d54..60987608b9 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -76,7 +76,7 @@ describe('sessions.rename', () => { expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') - const event = source.events.findLast(item => item.type === 'session/title') + const event = source.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.seq).toBe(renamed.value.seq) expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) }) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index e2c50c88a7..91bdcb67ee 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -34,6 +34,16 @@ function event(type: string, seq: number, data: unknown = {}): SessionEvent { } as SessionEvent } +function eventSession(header: SessionHeader, events: readonly SessionEvent[]): Session { + return { + id: header.id, + header, + seq: events.length, + eventAt: (seq: number) => events[seq], + snapshotEvents: (fromSeq = 0, toSeqExclusive = events.length) => events.slice(fromSeq, toSeqExclusive), + } as unknown as Session +} + function cold( ctx: Context, header: SessionHeader, @@ -164,12 +174,10 @@ describe('SessionHistoryController', () => { [Symbol.asyncIterator]() const opening = iterator.next() - ctx.emit('session/event', { - id: SessionId('unrelated'), events: [event('fixture/other', 0)], - } as unknown as Session, event('fixture/other', 0)) - ctx.emit('session/event', { - id: sessionId, events: [event('fixture/start', 0)], - } as unknown as Session, event('fixture/start', 0)) + const unrelated = event('fixture/other', 0) + const start = event('fixture/start', 0) + ctx.emit('session/event', eventSession({ ...header, id: SessionId('unrelated') }, [unrelated]), unrelated) + ctx.emit('session/event', eventSession(header, [start]), start) inspected.resolve({ meta: header, events: [event('fixture/start', 0)] }) await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -195,7 +203,7 @@ describe('SessionHistoryController', () => { observed.resolve({ source: 'live', header: attached.header, - events: attached.events, + events: attached.snapshotEvents(), cursor: attached.seq - 1, projections: { asOfSeq: attached.seq - 1, values: {} }, retain: vi.fn(), @@ -293,10 +301,7 @@ describe('SessionHistoryController', () => { await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) const skipped = event('fixture/skipped', 1) const gap = event('fixture/gap', 2) - live.ctx.emit('session/event', { - id: session.id, - events: [event('fixture/start', 0), skipped, gap], - } as unknown as Session, gap) + live.ctx.emit('session/event', eventSession(session.header, [event('fixture/start', 0), skipped, gap]), gap) await expect(followed.next()).rejects.toMatchObject({ code: 'gateway/internal' }) }) diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index a4b4e9dc19..f3814ccabe 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index a150b54113..ad1cdc0eab 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index e6759ab098..0da9c239e2 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index ab21021d04..99e31ef119 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 6186e91752..f540e88409 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 9c3e1b6b0f..7f1d904f68 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 9aabd94092..949b18fce1 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 143d77308e..b80a50fabc 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 3353859b35..6ad474cc64 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 22e2578838..5f2ad0e4b7 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -17,7 +17,7 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' // Empty type imports carry the loader Context merge for the settlement await // and the cmdline Context merge for the appExit host value. import type {} from '@deepseek-ai/cordis-plugin-loader' @@ -60,12 +60,16 @@ export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stde } /** Aggregate the last assistant text and turn outcome in one owned interval. */ -function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome { +function summarize(session: Session, firstSeq: number): RunOutcome { let started = false let text = '' let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined - for (const event of events) { - if (event.seq < firstSeq) continue + const length = session.seq + for (let seq = firstSeq; seq < length; seq++) { + const event = session.eventAt(seq) + if (event === undefined) { + throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`) + } if (event.type === 'turn/start') { started = true continue @@ -197,7 +201,7 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { stopReasoning() } await sessions.flush(agent.session) - const outcome = summarize(agent.session.events, firstSeq) + const outcome = summarize(agent.session, firstSeq) io.stdout.write(outcome.text + '\n') if (outcome.reason?.kind === 'error') { io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index fb86ff8387..bc788682bf 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -310,6 +310,21 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('fails when an event below the captured Session length cannot be read', async () => { + const test = await bench({ + afterPrompt(session, message) { + appendTurn(session, 1, message, 'unreachable', true) + Object.defineProperty(session, 'eventAt', { value: () => undefined }) + }, + }) + expect(await test.run()).toMatchObject({ + code: 1, + out: '', + err: 'dsh: headless summary cannot read seq 0 below captured length 7\n', + }) + await test.ctx.fiber.dispose() + }) + it('reports a direct Agent creation failure', async () => { const ctx = new Context() let err = '' diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 165f74bfa3..64504dba29 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 8589093590..1a8c341479 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0ba57ce2b9..0d7157e23c 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index bd863014f4..4bc6a1c61f 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 3020c80326..11a7f882b8 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -278,8 +278,7 @@ export class ConnectionController { this.callSink(() => { this.sinks.onConnected?.(host) }) } } catch { - // Transport failure: treat as generation failure, then enter the shared retry path. - if (!ac.signal.aborted) ac.abort() + // Source settlement and controller cancellation already abort the generation. } await failed @@ -306,12 +305,12 @@ export class ConnectionController { } } -/** Await source readiness without letting a stalled carrier wedge startup forever. */ +/** Await source readiness while reporting, but not cancelling, a slow Host. */ function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { let settled = false const timeout = setTimeout(() => { - finish({ error: new Error(`connection generation was not ready within ${String(timeoutMs)}ms`) }) + console.warn(`[connection] generation is still not ready after ${String(timeoutMs)}ms`) }, timeoutMs) const aborted = (): void => { finish({ error: new Error('connection generation aborted', { cause: signal.reason }) }) diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 3b9432995f..681f5ac242 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -568,7 +568,8 @@ describe('connection lifecycle', () => { } }) - it('rejects and retries a generation whose source never reports ready', async () => { + it('reports but retains a generation whose source is slow to report ready', async () => { + vi.useFakeTimers() const source = new FakeGenerationSource() source.suppressReady = true let connected = 0 @@ -580,13 +581,16 @@ describe('connection lifecycle', () => { ) controller.start() try { - await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) expect(source.activeCount).toBe(1) - await new Promise(resolve => setTimeout(resolve, 45)) + await vi.advanceTimersByTimeAsync(20) expect(connected).toBe(0) + expect(source.activeCount).toBe(1) + expect(warnSpy).toHaveBeenCalledWith('[connection] generation is still not ready after 20ms') } finally { controller.stop() warnSpy.mockRestore() + vi.useRealTimers() } }) diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 85aff120ad..44435ef224 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 8f695e8780..8a8e39d433 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index ec09842019..d0672fa18d 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index d8990587c5..81a0aa5186 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 1a6f3c2536..5ce6c2fb89 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index d79d0c4f0d..af83f7fd75 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 90ca1b9c0d..5b046fcc44 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 9aa7347eaf..f3368493f0 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 56b3e2a888..c57039ace5 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 5680c1498f..afadaddea4 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 017bd28a22..b5b0e91421 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 4465701cca..db291c8340 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index ede2a0e2d4..16b60e9889 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 0f7f5c1ead..0ad99131be 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index db44a0d0e5..28d2e06833 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 5c1d50e3af..a46e51af7c 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index e29cafee20..8d487be6ec 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 5dd609aff8..dd5739a61b 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 82c892b288..27db63c9cb 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index b5b4e63f52..0ad6338b80 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index d81c9bb3a7..91b394e826 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 06c26c2088..34d5bb4f65 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 20b5a88420..d1b7decda4 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index 979af09da9..6c1b2eaec9 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' +import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' import { FoldToggle } from './FoldToggle.tsx' import { writeClipboard } from './clipboard.ts' @@ -8,6 +8,7 @@ import { subscribeGrammarLoaded, type HighlightSpan, } from './markdown/highlight.ts' +import { useViewportHighlighting } from './markdown/useViewportHighlighting.ts' import css from './ReadBlock.module.css' /** @@ -72,6 +73,8 @@ export function ReadBlock({ maxLines = DEFAULT_READ_MAX_LINES, className, }: ReadBlockProps) { + const rootRef = useRef(null) + const highlighting = useViewportHighlighting(rootRef, lang) // Whole-window highlighting preserves multiline grammar context; copy uses // the same text without gutter or banner chrome. const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines]) @@ -79,7 +82,10 @@ export function ReadBlock({ // plain text while its language's grammar imported picks up highlighting. The // snapshot value is opaque; only its change across renders drives the memo. const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount) - const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded]) + const highlighted = useMemo( + () => highlighting ? highlightLines(raw, lang) : undefined, + [highlighting, raw, lang, loaded], + ) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) @@ -114,7 +120,7 @@ export function ReadBlock({ [line, highlighted?.[index]]) return ( -
+
{label ?? ''}
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 2a42092020..5ba93069dd 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -6,6 +6,7 @@ import { StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded, } from './highlight.ts' import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts' +import { useViewportHighlighting } from './useViewportHighlighting.ts' import css from './CodeBlock.module.css' export interface CodeBlockProps { @@ -58,6 +59,8 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code + const rootRef = useRef(null) + const highlighting = useViewportHighlighting(rootRef, lang) // Re-render when a lazy grammar finishes loading, so a fence that showed plain // text while its language's grammar imported picks up highlighting. The // snapshot value is opaque; only its change across renders drives the memo. @@ -78,6 +81,12 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL } | null>(null) const settledRef = useRef(false) const streamedBody = useMemo(() => { + if (!highlighting) { + sessionRef.current = null + lineCacheRef.current = null + settledRef.current = false + return undefined + } if (streaming !== true) { const previous = lineCacheRef.current if (previous !== null && previous.code === trimmed && previous.lang === lang) { @@ -123,12 +132,13 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body, } return body - }, [streaming, trimmed, lang, loaded]) + }, [streaming, highlighting, trimmed, lang, loaded]) const html = useMemo( - () => (streaming !== true && streamedBody === undefined ? highlightToHtml(trimmed, lang) : undefined), - [streaming, streamedBody, trimmed, lang, loaded], + () => (highlighting && streaming !== true && streamedBody === undefined + ? highlightToHtml(trimmed, lang) + : undefined), + [streaming, highlighting, streamedBody, trimmed, lang, loaded], ) - const rootRef = useRef(null) const [copied, setCopied] = useState(false) const onCopy = useCallback(() => { diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index d97d8d6c7f..889ccf9325 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -132,6 +132,15 @@ const LANG_ALIASES = new Map([ ['lua', 'lua'], ]) +/** + * Whether a language hint can use the shared syntax highlighter. + * @param lang - Language hint from a code surface. + * @returns Whether the hint resolves to a supported grammar. + */ +export function supportsHighlighting(lang: string | undefined): boolean { + return lang !== undefined && LANG_ALIASES.has(lang.toLowerCase()) +} + /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ const cssVariablesTheme = createCssVariablesTheme({ name: 'css-variables', diff --git a/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts b/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts new file mode 100644 index 0000000000..2f4114ce71 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from 'react' +import type { RefObject } from 'react' +import { supportsHighlighting } from './highlight.ts' + +const noop = (): void => {} + +/** One document-wide observer; activated elements leave it permanently. */ +class HighlightViewport { + private observer: IntersectionObserver | undefined + private readonly activators = new Map void>() + + observe(element: Element, activate: () => void): () => void { + if (typeof IntersectionObserver === 'undefined') { + activate() + return noop + } + this.observer ??= new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue + const current = this.activators.get(entry.target) + /* v8 ignore next -- the observer reports only elements still registered with it. */ + if (current === undefined) continue + this.activators.delete(entry.target) + this.observer?.unobserve(entry.target) + current() + } + this.releaseEmptyObserver() + }) + this.activators.set(element, activate) + this.observer.observe(element) + return () => { + this.activators.delete(element) + this.observer?.unobserve(element) + this.releaseEmptyObserver() + } + } + + private releaseEmptyObserver(): void { + if (this.activators.size > 0) return + this.observer?.disconnect() + this.observer = undefined + } +} + +const highlightViewport = new HighlightViewport() + +/** + * Activate one supported code surface when it first intersects the viewport. + * Activation lasts for the component lifetime; browsers without + * IntersectionObserver activate immediately. + * @param target - Code surface whose plain rendering reserves its geometry. + * @param lang - Optional language hint. + * @returns Whether this component may build highlighted output. + */ +export function useViewportHighlighting( + target: RefObject, + lang: string | undefined, +): boolean { + const supported = supportsHighlighting(lang) + const [activated, setActivated] = useState(false) + const activate = useCallback(() => { setActivated(true) }, []) + + useEffect(() => { + if (activated || !supported) return + const element = target.current + /* v8 ignore next -- React attaches the host ref before running effects. */ + if (element === null) return + return highlightViewport.observe(element, activate) + }, [activate, activated, supported, target]) + + return activated && supported +} diff --git a/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx b/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx new file mode 100644 index 0000000000..5c7e8816e8 --- /dev/null +++ b/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ReadBlock } from '../src/ReadBlock.tsx' +import { CodeBlock } from '../src/markdown/CodeBlock.tsx' +import { markdownLabels, readBlockLabels } from './labels.client.ts' + +class IntersectionObserverStub { + static instances: IntersectionObserverStub[] = [] + + readonly observed = new Set() + readonly unobserved = new Set() + disconnected = false + + constructor(private readonly callback: IntersectionObserverCallback) { + IntersectionObserverStub.instances.push(this) + } + + observe(element: Element): void { + this.observed.add(element) + } + + unobserve(element: Element): void { + this.observed.delete(element) + this.unobserved.add(element) + } + + disconnect(): void { + this.disconnected = true + this.observed.clear() + } + + takeRecords(): IntersectionObserverEntry[] { + return [] + } + + intersect(element: Element, isIntersecting: boolean): void { + this.callback( + [{ target: element, isIntersecting } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ) + } +} + +beforeEach(() => { + IntersectionObserverStub.instances = [] + vi.stubGlobal('IntersectionObserver', IntersectionObserverStub) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('viewport-activated syntax highlighting', () => { + it('keeps offscreen blocks plain and permanently activates only intersecting blocks', async () => { + const view = render( + <> + + + + , + ) + const blocks = [...view.container.querySelectorAll('.md-code-block')] + expect(blocks).toHaveLength(3) + expect(IntersectionObserverStub.instances).toHaveLength(1) + const observer = IntersectionObserverStub.instances[0]! + expect(observer.observed.size).toBe(3) + expect(view.container.querySelectorAll('pre.shiki')).toHaveLength(0) + + act(() => { observer.intersect(blocks[0]!, false) }) + expect(view.container.querySelectorAll('pre.shiki')).toHaveLength(0) + + act(() => { + observer.intersect(blocks[0]!, true) + observer.intersect(blocks[1]!, true) + }) + await waitFor(() => { + expect(blocks[0]!.querySelector('pre.shiki')).not.toBeNull() + expect(blocks[1]!.querySelector('pre.shiki')).not.toBeNull() + }) + expect(blocks[2]!.querySelector('pre.shiki')).toBeNull() + expect(observer.unobserved.has(blocks[0]!)).toBe(true) + + act(() => { observer.intersect(blocks[0]!, false) }) + expect(blocks[0]!.querySelector('pre.shiki')).not.toBeNull() + + view.rerender( + <> + + + + , + ) + expect(blocks[0]!.querySelector('pre.shiki')?.textContent).toBe('const first = 10') + + act(() => { observer.intersect(blocks[2]!, true) }) + await waitFor(() => { expect(blocks[2]!.querySelector('pre.shiki')).not.toBeNull() }) + expect(observer.disconnected).toBe(true) + }) + + it('does not observe an unsupported language', () => { + const view = render( + , + ) + expect(view.container.querySelector('pre.shiki')).toBeNull() + expect(IntersectionObserverStub.instances).toHaveLength(0) + }) + + it('releases the shared observer when the last pending block unmounts', () => { + const view = render() + const block = view.container.querySelector('.md-code-block')! + const observer = IntersectionObserverStub.instances[0]! + + view.unmount() + + expect(observer.unobserved.has(block)).toBe(true) + expect(observer.disconnected).toBe(true) + }) + + it('highlights immediately when IntersectionObserver is unavailable', () => { + vi.stubGlobal('IntersectionObserver', undefined) + const view = render() + expect(view.container.querySelector('pre.shiki')).not.toBeNull() + }) + + it('keeps an intersecting streaming block plain until its lazy grammar loads', async () => { + const view = render( + , + ) + const block = view.container.querySelector('.md-code-block')! + const observer = IntersectionObserverStub.instances[0]! + + act(() => { observer.intersect(block, true) }) + expect(block.querySelector('pre.shiki')).toBeNull() + + await waitFor(() => { expect(block.querySelector('pre.shiki')).not.toBeNull() }, { timeout: 5_000 }) + }) + + it('keeps a read card plain until that card intersects', async () => { + const view = render( + , + ) + const block = view.container.querySelector('[data-read]')! + expect(block.querySelectorAll('[class^="_content_"] span')).toHaveLength(0) + const observer = IntersectionObserverStub.instances[0]! + + act(() => { observer.intersect(block, true) }) + await waitFor(() => { + expect(block.querySelectorAll('[class^="_content_"] span[style]').length).toBeGreaterThan(1) + }) + }) +}) diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 211ecf47be..6732995f6a 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 21d7154bb5..6dd17917d8 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 9cae9286e8..e408b86b21 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 4b49ccc137..e53bf2ed61 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a601a425fe..33dda0fa3e 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 62030a9515..03be8d7a4b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index d623b2c064..f0d5709c97 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 3e7dd4e674..d57a402348 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 808df9154a..7aa0911c2c 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 48fe793dcd..d64f4b9410 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 4daa55eaf8..88e84dd83c 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 2f83bf640b..5b480ae3e4 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 27c4e2853d..18cad207e6 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index f44f524760..bb7d37f4e9 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index e0f98cfe8c..bf59531e60 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index fc4f2dfb6b..b3a57d921f 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index c4f97bdc6a..9b256fb0be 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 855be838c6..2bf43f0552 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 321a2598c7..f8f70de86c 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index d051df25f5..c862df293d 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 25f7a326a7..7884f95a59 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 35d837f5b4..07203b0629 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index c083aa3d52..456ea1e06b 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index 97229590b7..7cc9d3331e 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -121,7 +121,7 @@ function expectLastLifecycle( args: string, outcome: CommandResult, ): string { - const lifecycle = test.agent.session.events + const lifecycle = test.agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .slice(-2) const runEvent = lifecycle[0] diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index 95358b0036..e4cbedd42a 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -130,7 +130,7 @@ describe('command-compact real Loader composition', () => { text: 'Compacted 3 history items (~99 tokens).', sourceEventSeq: RESULT.summarySeq, }) - expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([ + expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([ { type: 'command/run', data: { diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 8a84e68267..a54fb76eb6 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index f5fba599a4..b609f949c8 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -162,7 +162,7 @@ export async function compactSurfaceRegion( ): Promise { if (options.owner === null) signal?.throwIfAborted() const selection = validateSurfaceRegion(session, start, end) - const entryState = inspectCompactionEntryState(session.events) + const entryState = inspectCompactionEntryState(session) assertCompactionInactive( entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, @@ -305,7 +305,7 @@ function assertCompactionInactive( * @param stage - operation label included in the busy diagnostic. */ export function assertNoActiveCompaction(session: Session, stage: string): void { - const entryState = inspectCompactionEntryState(session.events) + const entryState = inspectCompactionEntryState(session) assertCompactionInactive( entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, @@ -510,11 +510,10 @@ function buildSummarizationInput( shadowedSeqs: readonly number[], ): SummarizationInput { const header = session.requestHeader() - const events = session.events const regionMessages = shadowedSeqs // shadowedSeqs are current surface seqs, so each is a valid log index. // oxlint-disable-next-line typescript/no-non-null-assertion - .map(seq => session.deriveEventMessage(events[seq]!)) + .map(seq => session.deriveEventMessage(session.eventAt(seq)!)) .filter((message): message is Message => message !== null) return { ...header?.system === undefined ? {} : { system: header.system }, @@ -524,15 +523,15 @@ function buildSummarizationInput( } /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */ -function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState { +function inspectCompactionEntryState(session: Session): CompactionEntryState { let openTurn: number | null = null let openTurnStateKnown = false let unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined let compactionEntryStateKnown = false let latestEndSeedSeq: number | undefined - for (let index = events.length - 1; index >= 0; index -= 1) { + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! + const event = session.eventAt(seq)! if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') { latestEndSeedSeq = event.seq } diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 82eadaa71e..20125c18c6 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -609,7 +609,7 @@ describe('pressure measurement and retention', () => { await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() expect(session.surface.replaceGeneration).toBe(generation) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('does nothing below threshold and compacts a priced head above threshold', async () => { @@ -837,10 +837,10 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) - const original = session.events.find(event => event.type === 'tool/result') + const original = session.snapshotEvents().find(event => event.type === 'tool/result') expect(original?.type === 'tool/result' && original.data.message.content[0].content[0]) .toEqual({ type: 'text', text: 'X'.repeat(3_000) }) - expect(session.events.filter(event => + expect(session.snapshotEvents().filter(event => event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0) }) }) @@ -866,7 +866,7 @@ describe('compaction region transaction', () => { expect(result.shadowedTokenCount).toBeGreaterThan(0) expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1') - const summary = session.events.findLast(event => event.type === 'compaction/summary') + const summary = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, shadowedTokenCount: result.shadowedTokenCount, @@ -882,7 +882,7 @@ describe('compaction region transaction', () => { expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) - const replay = Session.create(SessionId('replay'), [...session.events]) + const replay = Session.create(SessionId('replay'), session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) @@ -1012,7 +1012,7 @@ describe('compaction region transaction', () => { agent(session, MODEL), )).rejects.toThrow('summary unavailable') expect(session.surface.nodes).toEqual(before) - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable' }) }) @@ -1026,7 +1026,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toBe('plain failure') - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'plain failure' }) }) @@ -1046,7 +1046,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) }) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) }) it('rejects concurrent surface appends before committing the replacement', async () => { @@ -1065,7 +1065,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/session surface changed/) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('rejects a non-shrinking framed summary under the conversation meter', async () => { @@ -1082,7 +1082,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/summary is not smaller/) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('lets a model-independent custom summarizer compact without a conversation model', async () => { @@ -1333,7 +1333,7 @@ describe('default one-shot summarizer', () => { const session = conversation(3, 'large history '.repeat(500)) const nodes = session.surface.nodes await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL) - expect(session.events.findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ summary: [{ type: 'text', text: 'routed summary' }], llmStreamCall: true, provider: 'routed-summary-provider', @@ -1459,7 +1459,7 @@ describe('automatic listener and loader composition', () => { next: () => Promise = () => Promise.resolve(undefined), ): Promise { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } - const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 + const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal }, @@ -1479,11 +1479,11 @@ describe('automatic listener and loader composition', () => { }) const pressured = conversation(4) await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) - expect(pressured.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) const small = conversation(1) await preStep(ctx, agent(small, MODEL)) - expect(small.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) @@ -1500,7 +1500,7 @@ describe('automatic listener and loader composition', () => { .resolves.toEqual({ kind: 'enter', messages: [] }) expect(compactIfNeeded).not.toHaveBeenCalled() - expect(pressured.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(pressured.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('warns and continues after operational failures, including non-Errors', async () => { @@ -1516,7 +1516,7 @@ describe('automatic listener and loader composition', () => { await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] }) expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('warns once per routed target when proactive pressure has no context metadata', async () => { @@ -1575,7 +1575,7 @@ describe('automatic listener and loader composition', () => { expect(decision).toBe(true) expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(session.surface.nodes).toContain(retainedSeq) }) @@ -1594,7 +1594,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) expect(compact.calls).toHaveLength(0) }) @@ -1612,7 +1612,7 @@ describe('automatic listener and loader composition', () => { const session = toolConversation() expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(compact.calls).toHaveLength(1) expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') }) @@ -1635,8 +1635,8 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2) - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable after prune' }) expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface')) }) @@ -1838,10 +1838,10 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - const summaries = session.events.filter(event => event.type === 'compaction/summary').length + const summaries = session.snapshotEvents().filter(event => event.type === 'compaction/summary').length expect(summaries).toBe(1) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) - expect(session.events.filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) + expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) }) it('auto:false installs neither automatic listener', async () => { @@ -1853,7 +1853,7 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -1885,7 +1885,7 @@ describe('automatic listener and loader composition', () => { const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) }) @@ -2025,7 +2025,7 @@ describe('route-priced image pressure', () => { const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL) expect(result).not.toBeNull() - const summaryEvent = session.events.find(event => event.type === 'compaction/summary') + const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summaryEvent).toBeDefined() const shadowedHeuristic = before.nodes .filter(node => result?.shadowedSeqs.includes(node.seq)) diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 1dacfe2114..f7b44e1940 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -188,7 +188,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function overflowHistorySeed(): SessionEvent[] { +function overflowHistorySeed(): readonly SessionEvent[] { const session = Session.create(SessionId('overflow-history-seed')) for (let turn = 1; turn <= 2; turn += 1) { const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' @@ -215,7 +215,7 @@ function overflowHistorySeed(): SessionEvent[] { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - return [...session.events] + return session.snapshotEvents() } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { @@ -233,8 +233,8 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () await waitForIdle(ctx, agent) expect(agent.session.requestHeader()?.config.model).toBe('mock') - expect(agent.session.events.some(event => event.type === 'compaction/summary')).toBe(true) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -250,7 +250,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() const compactStart = events.find(event => event.type === 'compaction/start') expect(compactStart).toBeDefined() const precedingResult = events.findLast(event => @@ -282,7 +282,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() // A compaction ran: at least one checkpoint landed on the surface. const checkpoints = events.filter( (e): e is SurfaceEvent => @@ -356,7 +356,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(retry).toContain('RECOVERY CHECKPOINT') expect(retry).not.toContain('OLD HISTORY SENTINEL') - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() const stepStart = events.find(event => event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, )! @@ -419,11 +419,11 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data)) .toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) - expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) .toEqual([3]) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index a0497f8041..84397a5ada 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -234,8 +234,8 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus return { ctx, compact: new GatedCompactionEngine(ctx, { auto: false }), flushes: () => flushes } } -function compactEvents(session: Session): Array { - return session.events.filter(event => event.type.startsWith('compaction/')) +function compactEvents(session: Session): SessionEvent[] { + return session.snapshotEvents().filter(event => event.type.startsWith('compaction/')) } describe('compactNow through the real loop', () => { @@ -296,14 +296,14 @@ describe('compactNow through the real loop', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - const start = agent.session.events.findLast(event => event.type === 'compaction/start') + const start = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/start') const injected = agent.inbox.nextStep.find(message => message.source.kind === 'plugin' && message.source.plugin === 'test') - const end = agent.session.events.findLast(event => event.type === 'compaction/end') + const end = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(injected).toBeDefined() expect(end).toBeDefined() - expect(agent.session.events.some(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.id === injected?.id)).toBe(false) agent.followup(createUserMessage({ @@ -335,7 +335,7 @@ describe('compactNow through the real loop', () => { expect(attempts).toEqual(['compaction/start', 'compaction/summary']) expect(result).not.toBeNull() expect(derivedText(agent.session)[0]).toContain('checkpoint') - expect(agent.session.events.filter(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0) const types = compactEvents(agent.session).map(event => event.type) expect(types).toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) @@ -355,7 +355,7 @@ describe('compactNow through the real loop', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('releases turn admission after a summarizer failure and records the failed attempt', async () => { @@ -405,14 +405,14 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) - expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) - const start = session.events.findLast(event => event.type === 'compaction/start') - const summaryEvent = session.events.findLast(event => event.type === 'compaction/summary') - const checkpoint = session.events.findLast( + expect(session.snapshotEvents().filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const summaryEvent = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') + const checkpoint = session.snapshotEvents().findLast( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && isCompactCheckpointSource(event.data.source), ) - const end = session.events.findLast(event => event.type === 'compaction/end') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } expect(start?.data).toEqual({ ...correlated, turn: null }) expect(summaryEvent?.data.sourceCommandId).toBe(commandId) @@ -442,9 +442,9 @@ describe('compactNow transaction and failure classification', () => { compactionId: CompactionId('stale-manual-compaction'), turn: null, }) - const reloaded = Session.create(SessionId('stale-orphan'), [...original.events]) - const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') - const orphan = reloaded.events.find(event => event.type === 'compaction/start') + const reloaded = Session.create(SessionId('stale-orphan'), original.snapshotEvents()) + const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed') + const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start') const agent = fakeAgent(reloaded, () => () => undefined) expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER) @@ -461,7 +461,7 @@ describe('compactNow transaction and failure classification', () => { }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) - const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events]) + const reloaded = Session.create(SessionId('reloaded-orphan'), original.snapshotEvents()) const agent = fakeAgent(reloaded, () => () => undefined) await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() @@ -564,7 +564,7 @@ describe('compactNow transaction and failure classification', () => { expect(session.surface.replaceGeneration).toBe(generation + 1) expect(session.surface.nodes).not.toContain(head) expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end']) - expect(session.events.some(event => event.type === 'user/message' + expect(session.snapshotEvents().some(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source))).toBe(false) }) @@ -583,7 +583,7 @@ describe('compactNow transaction and failure classification', () => { expect(causeOf(error).message).toBe('boundary rejected') vi.restoreAllMocks() expect(flushes()).toBe(0) - expect(session.events.findLast(event => event.type.startsWith('compaction/'))?.type) + expect(session.snapshotEvents().findLast(event => event.type.startsWith('compaction/'))?.type) .toBe('compaction/summary') expect(compactEvents(session).filter(event => event.type === 'compaction/start')).toHaveLength(1) @@ -649,7 +649,7 @@ describe('compactNow transaction and failure classification', () => { vi.restoreAllMocks() expect(error.code).toBe('commit') expect(released).toBe(1) - const end = session.events.findLast(event => event.type === 'compaction/end') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.type === 'compaction/end' && end.data.error).toContain('summary record rejected') expect(end?.type === 'compaction/end' && end.data.turn).toBeNull() }) @@ -685,8 +685,8 @@ describe('compactNow transaction and failure classification', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - expect(session.events.some(event => event.type === 'turn/start')).toBe(false) - expect(session.events.find(event => event.type === 'compaction/start')?.data) + expect(session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) + expect(session.snapshotEvents().find(event => event.type === 'compaction/start')?.data) .toEqual({ compactionId: result?.compactionId, turn: null }) }) @@ -698,9 +698,9 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) - const start = session.events.findLast(event => event.type === 'compaction/start') - const end = session.events.findLast(event => event.type === 'compaction/end') + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) @@ -716,7 +716,7 @@ describe('compactNow transaction and failure classification', () => { const reserve = vi.fn(() => testCase.release) const measure = vi.spyOn(ctx.tokenMeter, 'measure') const agent = fakeAgent(testCase.session, reserve) - const before = [...testCase.session.events] + const before = testCase.session.snapshotEvents() const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) const controller = new AbortController() controller.abort(reason) @@ -731,7 +731,7 @@ describe('compactNow transaction and failure classification', () => { expect(reserve).not.toHaveBeenCalled() expect(measure).not.toHaveBeenCalled() expect(compact.calls).toHaveLength(0) - expect(testCase.session.events).toEqual(before) + expect(testCase.session.snapshotEvents()).toEqual(before) vi.restoreAllMocks() } }) @@ -780,7 +780,7 @@ describe('compactNow transaction and failure classification', () => { await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end']) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('waits for the durability checkpoint before cancellation wins and admission releases', async () => { @@ -824,7 +824,7 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - const summary = session.events.find(event => event.type === 'compaction/summary') + const summary = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summary?.type === 'compaction/summary' && summary.data.rawOutput).toEqual(compact.rawOutput) expect(summary?.type === 'compaction/summary' && summary.data.usage).toEqual(compact.usage) }) @@ -839,8 +839,8 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - const start = session.events.findLast(event => event.type === 'compaction/start') - const end = session.events.findLast(event => event.type === 'compaction/end') + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(end).toBeDefined() expect(end!.time - start!.time).toBeGreaterThan(0) diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 411246c986..6a0dda7491 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts index 7e261e15fa..d3f3212c1e 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/index.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts @@ -136,7 +136,7 @@ export class ToolResultPruner extends Service { pruneSession(session: Session): PruneResult { const candidates: SnapshotCandidate[] = [] for (const seq of [...session.surface.nodes]) { - const event = session.events[seq] + const event = session.eventAt(seq) /* v8 ignore next -- surface seqs are validated contiguous log references. */ if (event?.type === 'tool/result') candidates.push({ seq, event }) } diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index e21a3635a7..2fc6bb9dd4 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -185,8 +185,8 @@ describe('ToolResultPruner session transaction', () => { expect(entry).toMatchObject({ originalSeq, callId: ToolCallId('one'), charsBefore: 100 }) expect(entry.charsAfter).toBeLessThanOrEqual(50) - const original = session.events[originalSeq]! - const replacement = session.events[entry.replacementSeq]! as SurfaceEvent + const original = session.snapshotEvents()[originalSeq]! + const replacement = session.snapshotEvents()[entry.replacementSeq]! as SurfaceEvent expect(original).toMatchObject({ type: 'tool/result', data: { @@ -219,7 +219,7 @@ describe('ToolResultPruner session transaction', () => { // Shadow-price protocol: the metering event sits directly before the // replacement and prices the shadowed node with the shared estimator. if (original.type !== 'tool/result') throw new Error('original is not a tool/result') - expect(session.events[entry.replacementSeq - 1]).toMatchObject({ + expect(session.snapshotEvents()[entry.replacementSeq - 1]).toMatchObject({ type: 'compaction/prune', data: { shadowedRange: { start: originalSeq, end: originalSeq }, @@ -254,7 +254,7 @@ describe('ToolResultPruner session transaction', () => { turn: 2, }) service().pruneSession(session) - const replay = Session.create(session.id, [...session.events]) + const replay = Session.create(session.id, session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration) }) diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 3aaf8a5dcb..7dec4528ca 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts index 7252fb06b0..df82f348ee 100644 --- a/packages/compaction/compaction/src/invariant.ts +++ b/packages/compaction/compaction/src/invariant.ts @@ -251,8 +251,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): SessionTrace => { const trace: SessionTrace = { openTurn: null, compaction: undefined } traces.set(session, trace) - const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events) - for (const event of session.events) { + const events = session.snapshotEvents() + const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events) + for (const event of events) { // Constructor-seed repair boundaries can precede the end-seed marker // that proves an inherited orphan stale. Replay that inherited prefix // without letting the soon-to-be-cleared bracket veto its repair. diff --git a/packages/compaction/compaction/src/tool-pairing.ts b/packages/compaction/compaction/src/tool-pairing.ts index 72ebb4dad6..c492d53a70 100644 --- a/packages/compaction/compaction/src/tool-pairing.ts +++ b/packages/compaction/compaction/src/tool-pairing.ts @@ -37,15 +37,6 @@ function eventDelta(event: SessionEvent): number { } } -/** Read and validate the event named by a surface sequence. */ -function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent { - const event = events[seq] - if (event === undefined || event.seq !== seq) { - throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`) - } - return event -} - /** Fold surface sequences not yet in the cache into its balance state. */ function extendCache( session: Session, @@ -56,11 +47,14 @@ function extendCache( const tail = seqs.slice(processed) // Validate the unseen tail before mutating the live cache, so a corrupt // append cannot leave a partially advanced state behind. - const events = session.events const pendingCuts: boolean[] = [] let inProgressToolCalls = cache.inProgressToolCalls for (const seq of tail) { - inProgressToolCalls += eventDelta(eventForSeq(events, seq)) + const event = session.eventAt(seq) + if (event === undefined || event.seq !== seq) { + throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`) + } + inProgressToolCalls += eventDelta(event) if (inProgressToolCalls < 0) { throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`) } diff --git a/packages/compaction/compaction/tests/compaction.spec.ts b/packages/compaction/compaction/tests/compaction.spec.ts index 220dff5648..8b02f5f811 100644 --- a/packages/compaction/compaction/tests/compaction.spec.ts +++ b/packages/compaction/compaction/tests/compaction.spec.ts @@ -130,7 +130,7 @@ describe('CompactionEngine seam', () => { const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm')) - const startEvent = session.events.find(e => e.type === 'compaction/start') + const startEvent = session.snapshotEvents().find(e => e.type === 'compaction/start') expect(startEvent).toBeDefined() // Log-only: the compiler rejects surfaceOp on compaction/* (not a SurfaceEventType); // verify the runtime value is absent. @@ -141,13 +141,13 @@ describe('CompactionEngine seam', () => { expect(result.endSeq).toBeGreaterThan(result.summarySeq) expect(result.shadowedRange).toEqual({ start: original.seq, end: original.seq }) expect(result.shadowedSeqs).toEqual([original.seq]) - const checkpoint = session.events.find(event => event.type === 'user/message' + const checkpoint = session.snapshotEvents().find(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source)) expect(checkpoint?.type === 'user/message' && checkpoint.data.source) .toEqual(compactCheckpointSource(result.compactionId)) expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false) expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false) - expect(session.events.filter(e => e.type.startsWith('compaction/')).map(e => e.type)) + expect(session.snapshotEvents().filter(e => e.type.startsWith('compaction/')).map(e => e.type)) .toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) }) diff --git a/packages/compaction/compaction/tests/invariant.spec.ts b/packages/compaction/compaction/tests/invariant.spec.ts index 9a1165ab57..1aa8682e6a 100644 --- a/packages/compaction/compaction/tests/invariant.spec.ts +++ b/packages/compaction/compaction/tests/invariant.spec.ts @@ -68,9 +68,9 @@ describe('compaction invariants', () => { const source = Session.create(SessionId('stale-compaction-source')) source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null }) const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)) + expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['compaction/start', 'session/end-seed']) await ctx.plugin(InvariantRegistry) @@ -89,9 +89,9 @@ describe('compaction invariants', () => { startTurn(source) source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)) + expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'compaction/start', 'session/end-seed']) await ctx.plugin(InvariantRegistry) @@ -111,9 +111,9 @@ describe('compaction invariants', () => { startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)).toEqual([ + expect(replayed.snapshotEvents().map(event => event.type)).toEqual([ 'compaction/start', 'turn/start', 'turn/end', @@ -138,9 +138,9 @@ describe('compaction invariants', () => { source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) source.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' }) const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.at(-1)?.type).toBe('session/end-seed') + expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') await ctx.plugin(InvariantRegistry) await expect(ctx.plugin(CompactionInvariant).then(() => undefined)) diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts index cd4e2a874f..8478f47edd 100644 --- a/packages/compaction/compaction/tests/tool-pairing.spec.ts +++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts @@ -7,7 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' const SURFACE = { surfaceOp: 'append' as const } function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number { - return session.events.filter(event => event.type === type)[nth]!.seq + return session.snapshotEvents().filter(event => event.type === type)[nth]!.seq } function surfaceSeq(session: Session, seq: number): number { @@ -241,41 +241,31 @@ describe('tool-pairing cache refresh', () => { ] const nodes: number[] = [0, 1, 2] let generation = 0 - let eventCollectionReads = 0 - let eventIndexReads = 0 - const trackedEvents = new Proxy(events, { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1 - return Reflect.get(target, property, receiver) as unknown - }, - }) + let eventReads = 0 const surface = { get nodes() { return nodes }, get replaceGeneration() { return generation }, } const session = { surface, - get events() { - eventCollectionReads += 1 - return trackedEvents + eventAt(seq: number) { + eventReads += 1 + return events[seq] }, } as unknown as Session expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true) expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) events.push({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } }, }) expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) events.push({ type: 'user/message', seq: 4, time: 4, @@ -286,8 +276,7 @@ describe('tool-pairing cache refresh', () => { }) nodes.push(4) expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true) - expect(eventCollectionReads).toBe(2) - expect(eventIndexReads).toBe(4) + expect(eventReads).toBe(4) events.push( { @@ -321,8 +310,7 @@ describe('tool-pairing cache refresh', () => { ) nodes.push(5, 6) expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true) - expect(eventCollectionReads).toBe(3) - expect(eventIndexReads).toBe(6) + expect(eventReads).toBe(6) events.push({ type: 'user/message', seq: 7, time: 7, @@ -334,8 +322,7 @@ describe('tool-pairing cache refresh', () => { nodes.splice(0, nodes.length, 7) generation += 1 expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true) - expect(eventCollectionReads).toBe(4) - expect(eventIndexReads).toBe(7) + expect(eventReads).toBe(7) }) it('rebuilds defensively when a same-generation surface entry count regresses', () => { @@ -355,7 +342,7 @@ describe('tool-pairing cache refresh', () => { ] const nodes: number[] = [0, 1] const session = { - events, + eventAt: (seq: number) => events[seq], surface: { nodes, replaceGeneration: 0 }, } as unknown as Session expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true) @@ -399,24 +386,24 @@ describe('tool-pairing corrupt surfaces', () => { it('throws when a current surface seq has no matching event or indexes the wrong event', () => { const missingSeq = 1 const missing = { - events: [{ + eventAt: (seq: number) => [{ type: 'user/message', seq: 0, time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', - } satisfies SessionEvent], + } satisfies SessionEvent][seq], surface: { nodes: [missingSeq], replaceGeneration: 0 }, } as unknown as Session expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/) const mismatchedSeq = 0 const mismatched = { - events: [{ + eventAt: (seq: number) => [{ type: 'user/message', seq: 99, time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', - } satisfies SessionEvent], + } satisfies SessionEvent][seq], surface: { nodes: [mismatchedSeq], replaceGeneration: 0 }, } as unknown as Session expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 8497a3a51c..973788e828 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index ace82c24b2..f614926241 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -53,7 +53,7 @@ function visibleBaselineSource( } } for (const seq of agent.session.surface.nodes.toReversed()) { - const event = agent.session.events[seq] + const event = agent.session.eventAt(seq) if (event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) return event.data.source @@ -228,7 +228,7 @@ export function apply(ctx: Context, config: Config): void { const alreadySupplied = desired !== undefined && ( claimed.some(message => sameContextPayload(message, desired)) || agent.session.surface.nodes.some((seq) => { - const event = agent.session.events[seq] + const event = agent.session.eventAt(seq) return event?.type === 'user/message' && sameContextPayload(event.data, desired) }) ) diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index 30e700eda5..fb25e262f9 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -137,13 +137,13 @@ function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { - const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const [seq, event] of agent.session.events.entries()) { - if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue + for (const seq of agent.session.surface.nodes) { + const event = agent.session.eventAt(seq) + if (event?.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { - if (visibleSeqs.has(seq)) visible.set(change.scope, change) + visible.set(change.scope, change) } } for (const message of authorityMessages) { diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 7f468bb6c8..c24dbcac75 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -68,7 +68,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function finalText(events: SessionEvent[]): string { +function finalText(events: readonly SessionEvent[]): string { const message = events.findLast(event => event.type === 'assistant/message') if (message?.type !== 'assistant/message') return '' return message.data.message.content @@ -84,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.events])).toContain(PROBE) + expect(finalText(live.agent.session.snapshotEvents())).toContain(PROBE) }, 120_000) it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => { @@ -96,7 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) + expect(finalText(live.agent.session.snapshotEvents())).toContain(NESTED_PROBE) }, 120_000) it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { @@ -109,7 +109,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - const events = [...live.agent.session.events] + const events = live.agent.session.snapshotEvents() const update = events.find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 1533709640..37e9db46a9 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -188,7 +188,7 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace return mountWorkspaceContextPlugin(ctx, config) } -function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { +function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): Agent { const id = SessionId('s1') const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) return { @@ -247,7 +247,7 @@ async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise + return agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) @@ -1097,7 +1097,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) expect(derivedText(agent)).toContain('repo rule') } finally { await rm(root, { recursive: true, force: true }) @@ -1116,14 +1116,14 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(root, [...original.session.events]) + const firstResume = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(root, [...firstResume.session.events]) + const secondResume = stubAgent(root, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) expect(baselineEvents(secondResume)).toHaveLength(1) - expect(secondResume.session.events.filter(event => event.type === 'user/message' + expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { await rm(root, { recursive: true, force: true }) @@ -1145,11 +1145,11 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - expect(resumed.session.events.filter(event => event.type === 'user/message' + expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { await ctx.fiber.dispose() @@ -1171,13 +1171,13 @@ describe('workspace context request injection', () => { const original = stubAgent(cwd) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(cwd, [...original.session.events]) + const firstResume = stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(cwd, [...firstResume.session.events]) + const secondResume = stubAgent(cwd, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) - expect(secondResume.session.events.filter(event => event.type === 'user/message' + expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md') expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root') @@ -1201,11 +1201,11 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') - const resumed = stubAgent(cwd, [...original.session.events]) + const resumed = stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - const update = resumed.session.events.findLast(event => event.type === 'user/message' + const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) expect(update?.type === 'user/message' && update.data.source.kind === 'agent-instructions' @@ -1238,7 +1238,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1257,7 +1257,7 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) - const repeated = stubAgent(root, [...resumed.session.events]) + const repeated = stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1291,7 +1291,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) - const claudeResume = stubAgent(root, [...original.session.events]) + const claudeResume = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions' @@ -1306,7 +1306,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - const restored = stubAgent(root, [...claudeResume.session.events]) + const restored = stubAgent(root, claudeResume.session.snapshotEvents()) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions' @@ -1341,7 +1341,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1355,7 +1355,7 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) - const repeated = stubAgent(root, [...resumed.session.events]) + const repeated = stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1385,7 +1385,7 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( @@ -1401,7 +1401,7 @@ describe('workspace context request injection', () => { expect(decision.messages.map(message => message.id)).toEqual([inserted?.id]) expect(resumed.inbox.nextStep).toEqual([]) - expect(resumed.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(resumed.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'agent-instructions' && message.source.baseline === true))).toHaveLength(1) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1431,7 +1431,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( @@ -1484,7 +1484,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( @@ -1525,7 +1525,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const removal = agent.session.events.find(event => event.type === 'user/message' + const removal = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.changes.some(change => change.action === 'remove')) expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({ @@ -1559,7 +1559,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const workspaceEvents = agent.session.events.filter(event => event.type === 'user/message' + const workspaceEvents = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(workspaceEvents).toHaveLength(2) expect(workspaceEvents.some(event => event.type === 'user/message' @@ -1568,7 +1568,7 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(2) } finally { await rm(root, { recursive: true, force: true }) @@ -1791,7 +1791,7 @@ describe('workspace context request injection', () => { // The first resumed pre-step retains the compatible visible baseline and // appends only the offline file transition needed to reach current state. await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) // Resume announces its lifecycle start before the first step. agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) @@ -1799,7 +1799,7 @@ describe('workspace context request injection', () => { const baselines = baselineEvents(resumed) expect(baselines).toHaveLength(1) - const latest = resumed.session.events.findLast(event => + const latest = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], @@ -1980,7 +1980,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const contexts = agent.session.events.filter(event => + const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', ) expect(contexts).toHaveLength(1) @@ -2559,14 +2559,14 @@ describe('dynamic nested workspace context injection', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } })) await agent.whenIdle() - expect(agent.session.events.filter(event => + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', )).toHaveLength(0) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } })) await agent.whenIdle() - const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts).toHaveLength(1) expect(adapter.requests).toHaveLength(3) expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n')) @@ -3541,7 +3541,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const resumed = stubAgent(root, [...agent.session.events]) + const resumed = stubAgent(root, agent.session.snapshotEvents()) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -3575,11 +3575,11 @@ describe('dynamic nested workspace context injection', () => { }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) - const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') + const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -4609,7 +4609,7 @@ describe('workspace context inbox synchronization', () => { callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncWorkspaceContext(ctx, original) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await ctx.tools.execute({ signal: testToolSignal, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index f9add39fcb..7c286ef51f 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 9d624cc0c4..c3833ab082 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 19c50dfc46..119bdc34bd 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index c8914d3695..d2b2a205ba 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -805,7 +805,7 @@ describe('session reference discovery and preparation', () => { expect(JSON.stringify(before)).toContain('durable referenced fact') expect(JSON.stringify(before)).toContain('use @source') expect(JSON.stringify(before)).not.toContain('later source mutation') - expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before) + expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(before) }) it('rejects direct invalid configuration before service publication', async () => { diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index ca543d05bb..76f3550933 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 8a1e7880c6..87f09e2241 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -77,14 +77,15 @@ function formatDuration(elapsedMs: number): string { /** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { - const start = agent.session.events.findLastIndex( - event => event.type === 'turn/start' && event.data.turn === turn, - ) - const entered = start < 0 - ? [] - : agent.session.events.slice(start + 1) - .flatMap(event => event.type === 'user/message' ? [event.data] : []) - return [...entered, ...proposed] + const entered: UserMessage[] = [] + for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) { + const event = agent.session.eventAt(seq) + if (event?.type === 'turn/start' && event.data.turn === turn) { + return [...entered.reverse(), ...proposed] + } + if (event?.type === 'user/message') entered.push(event.data) + } + return [...proposed] } function renderText( diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 187e7d85ae..90ec5f2ff2 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -161,11 +161,12 @@ function validateReading( /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ /** Validate all package-owned readings already present in one session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const [index, event] of session.events.entries()) { + const events = session.snapshotEvents() + for (const [index, event] of events.entries()) { if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) continue - validateReading(session.events.slice(0, index), event, fail) + validateReading(events.slice(0, index), event, fail) } } @@ -179,7 +180,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(session.events, event, fail) + validateReading(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 7f3aa63e17..ec8976fead 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -67,7 +67,7 @@ function openMessageTurn(session: Session, turn: number, clientTimeZone?: string function contextTexts(session: Session): string[] { const texts: string[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context') { @@ -168,7 +168,7 @@ describe('durable step context', () => { + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n' + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', ]) - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') // The reading is a `snapshot`-form context: one named contribution whose @@ -291,8 +291,8 @@ describe('durable step context', () => { const original = Session.create(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) - const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') - const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') + const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') if (user === undefined || reading === undefined) throw new Error('missing source surface events') original.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted history' }], @@ -304,15 +304,15 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - const resumed = Session.create(SessionId('resumed'), [...original.events]) + const resumed = Session.create(SessionId('resumed'), original.snapshotEvents()) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) - const beforeSkip = resumed.events.length + const beforeSkip = resumed.snapshotEvents().length await fire(ctx, resumedAgent, 2, 1) - expect(resumed.events).toHaveLength(beforeSkip) + expect(resumed.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(resumed)).toHaveLength(1) vi.setSystemTime(BASE + 1_000) @@ -334,14 +334,14 @@ describe('durable step context', () => { vi.setSystemTime(BASE + 500) openMessageTurn(first, 2) - const beforeSkip = first.events.length + const beforeSkip = first.snapshotEvents().length await fire(ctx, firstAgent, 2, 1) const independent = Session.create(SessionId('interval-independent')) openMessageTurn(independent, 1) await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1) - expect(first.events).toHaveLength(beforeSkip) + expect(first.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(first)).toHaveLength(1) expect(contextTexts(independent)).toHaveLength(1) }) @@ -460,7 +460,7 @@ describe('real agent-loop request history', () => { expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false) await ctx.fiber.dispose() }) @@ -482,9 +482,9 @@ describe('real agent-loop request history', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - const contexts = agent.session.events.filter( + const contexts = agent.session.snapshotEvents().filter( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') - const starts = agent.session.events.filter(event => event.type === 'step/start') + const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) for (let index = 0; index < contexts.length; index += 1) { @@ -504,7 +504,7 @@ describe('real agent-loop request history', () => { expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.') for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing') - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') await ctx.fiber.dispose() }) diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 46050f06bc..4a9762c3d5 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index af6de0f6b6..cc1c4985d3 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -121,7 +121,7 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tmux-context') { @@ -169,7 +169,7 @@ describe('tmux-context injection', () => { + 'window active=1, pane active=0, ' + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', ]) - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) if (event?.type !== 'user/message') throw new Error('missing tmux context') // `snapshot` form: one named contribution carrying exactly the reading the // model saw, so a consumer attributes it without re-splitting prose. diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index eeffcbc65a..71f526dde7 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 6bfef9cc8e..7df2edd3e0 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 80fdfba8f9..b5f65362f7 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -28,7 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail('a loop-built request must carry a frozen messages array') } - const events = session.events + const events = session.snapshotEvents() if (!events.some(event => event.type === 'step/start')) { return fail('a loop-built request with no step/start in its session log') } diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index 63b353ebae..e83f0dfc9d 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -33,8 +33,8 @@ export class RuntimeContextProjection { */ constructor(ctx: Context, session: Session) { const surface = new Set(session.surface.nodes) - for (let index = session.events.length - 1; index >= 0; index -= 1) { - const event = session.events[index] + for (let index = session.seq - 1; index >= 0; index -= 1) { + const event = session.eventAt(index) if (event?.type !== 'user/message' || !isOwned(event.data)) continue this.retained ??= null if (surface.has(event.seq)) { diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 395a0333c2..bdee0222c0 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -334,7 +334,7 @@ describe('AgentLoop initiator scope', () => { }]) const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request') expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i) - const call = handle.agent.session.events.find(event => event.type === 'tool/call') + const call = handle.agent.session.snapshotEvents().find(event => event.type === 'tool/call') expect(call?.type === 'tool/call' ? call.data.arguments : undefined) .toBe(JSON.stringify({ path: '/v1/capability' })) expect(captured).toBe(handle.agent) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index fc39e46a28..9507844521 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -35,7 +35,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })) - expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced']) + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['agent/inbox/spliced']) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) await agent.whenIdle() @@ -47,7 +47,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })) - const injected = agent.session.events.at(-1) + const injected = agent.session.snapshotEvents().at(-1) expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source) .toEqual({ kind: 'plugin', plugin: '' }) }) @@ -97,7 +97,7 @@ describe('Agent', () => { expect(() => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })) }).toThrow(/non-JSON-serializable/) - expect(agent.session.events).toHaveLength(0) + expect(agent.session.snapshotEvents()).toHaveLength(0) }) it('steer() while idle becomes a woken prompt turn', async () => { @@ -108,7 +108,7 @@ describe('Agent', () => { agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() - expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message')).toBe(true) expect(adapter.requests).toHaveLength(1) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 4abaf954b8..4c148d663d 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -71,7 +71,7 @@ describe('Agent.cancel()', () => { // The prompt ran: its user message is in the log and one turn completed. expect(userTexts(agent)).toEqual(['real prompt']) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) }) it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => { @@ -86,13 +86,13 @@ describe('Agent.cancel()', () => { // A waking send starts and claims synchronously, so keepInbox has no // pending item to preserve by the time this cancellation runs. agent.cancel({ kind: 'user' }, { keepInbox: true }) - expect(agent.session.events.some(event => + expect(agent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false) await agent.whenIdle() expect(agent.inbox.nextTurn).toHaveLength(0) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) const idle = waitForIdle(ctx, agent) @@ -146,7 +146,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['active', 'B']) expect(adapter.requests).toHaveLength(2) expect(agent.inbox.nextTurn).toHaveLength(0) - expect(agent.session.events.filter(e => e.type === 'turn/end').map(e => + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/end').map(e => e.type === 'turn/end' ? e.data.reason : null)).toEqual([ { kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }, @@ -196,7 +196,7 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') // No replay with nothing to run: the latched message is gone, so no // empty follow-up turn is recorded. - expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(1) }) it('latches a wake arriving deep into a slow abort convergence', async () => { @@ -255,9 +255,9 @@ describe('Agent.cancel()', () => { await new Promise(r => setTimeout(r, 30)) expect(userTexts(agent)).toEqual([]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(0) - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) expect(agent.status).toBe('idle') }) @@ -286,7 +286,7 @@ describe('Agent.cancel()', () => { await driverDone(agent) expect(agent.status).toBe('idle') - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) }) @@ -323,7 +323,7 @@ describe('Agent.cancel()', () => { replacementObservation = agent.whenIdle().then(() => ({ status: agent.status, requests: adapter.requests.length, - turns: agent.session.events.filter(event => event.type === 'turn/start').length, + turns: agent.session.snapshotEvents().filter(event => event.type === 'turn/start').length, })) agent.cancel({ kind: 'user' }) replacementRegistered.resolve(undefined) @@ -400,7 +400,7 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) expect(userTexts(agent)).toEqual(['go']) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(adapter.requests).toHaveLength(1) }) @@ -436,8 +436,8 @@ describe('Agent.cancel()', () => { expect(executions).toBe(0) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) - const call = agent.session.events.find(event => event.type === 'tool/call') - const result = agent.session.events.find(event => event.type === 'tool/result') + const call = agent.session.snapshotEvents().find(event => event.type === 'tool/call') + const result = agent.session.snapshotEvents().find(event => event.type === 'tool/result') expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1') expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ message: { @@ -477,7 +477,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toContain('second') // The second turn completed (its reply was streamed). - const reasons = agent.session.events.filter(e => e.type === 'turn/end') + const reasons = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(reasons.length).toBe(2) }) @@ -494,13 +494,13 @@ describe('Agent.cancel()', () => { // The prefix the user watched stream is committed as the step's message, // carrying the truncation marker and citing exactly the chunk events that // delivered it. - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'partial' }]) expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) - const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) + const chunkSeqs = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk').map(e => e.seq) expect(message?.sourceEventSeqs).toEqual(chunkSeqs) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end')) expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end')) @@ -530,7 +530,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'reasoning', text: 'thinking about it' }]) // A usage chunk delivered before the cancel travels with the finalized prefix. @@ -557,10 +557,10 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // The undispatched call is dropped whole — no dangling tool_use to pair. - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'reading the file' }]) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) }) it('cancel during error recovery does not finalize the failed stream', async () => { @@ -582,8 +582,8 @@ describe('Agent.cancel()', () => { // The failed stream's prefix stays off the surface: clients reset it on // retry, and provider failures commit nothing. - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) - const end = agent.session.events.find(e => e.type === 'turn/end') + expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) + const end = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') }) @@ -603,14 +603,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const messages = agent.session.events.filter(e => e.type === 'assistant/message') + const messages = agent.session.snapshotEvents().filter(e => e.type === 'assistant/message') expect(messages).toHaveLength(1) const message = messages[0]! expect(message.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'recovered' }]) expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined() // The abandoned attempt's chunks stay out of the completion's source set. - const doomedSeqs = agent.session.events + const doomedSeqs = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/chunk' && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial') .map(e => e.seq) @@ -633,7 +633,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) }) it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { @@ -662,7 +662,7 @@ describe('Agent.cancel()', () => { // log is balanced (the open step was closed by the cancel branch). expect(streamed).toBe(false) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -697,8 +697,8 @@ describe('Agent.cancel()', () => { expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false) - const types = agent.session.events.map(e => e.type) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(false) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -751,7 +751,7 @@ describe('Agent.cancel()', () => { // No turn opened, no step streamed, and a later prompt still runs (the marker // was reset). expect(streamed).toBe(false) - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(false) }) it('a running-listener cancellation replays replacement work at convergence', async () => { @@ -782,7 +782,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(2) }) it('a prompt queued during pre-step cancellation replays at convergence', async () => { @@ -805,7 +805,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(3) }) it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { @@ -826,10 +826,10 @@ describe('Agent.cancel()', () => { // started from the dropped steering. await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('idle') - const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts.length).toBe(1) // only the original (cancelled) turn // The steering text was dropped — it never reached the log. - const flat = agent.session.events + const flat = agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.data.content) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -860,7 +860,7 @@ describe('Agent.cancel()', () => { status: agent.status, requests: adapter.requests.length, users: userTexts(agent), - events: agent.session.events.map(event => event.type), + events: agent.session.snapshotEvents().map(event => event.type), })}`)) }, 1000) }), @@ -871,7 +871,7 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(2) expect(userTexts(agent)).toEqual(['original', 'replacement']) expect(agent.inbox.nextTurn).toHaveLength(0) - const reasons = agent.session.events + const reasons = agent.session.snapshotEvents() .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) @@ -898,7 +898,7 @@ describe('Agent.cancel()', () => { const runtimeReason: unknown = adapter.requests[0]?.signal?.reason expect(runtimeReason).toEqual({ kind: 'parent' }) expect(runtimeReason).toBe(supplied) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' }, @@ -919,7 +919,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await handle.dispose() - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -990,7 +990,7 @@ describe('Agent.cancel()', () => { const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await idle - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index e57c0e2109..ab338d3b01 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -73,7 +73,7 @@ describe('assistant replay provider and model fields', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const recorded = agent.session.events.find(event => event.type === 'assistant/message') + const recorded = agent.session.snapshotEvents().find(event => event.type === 'assistant/message') expect(recorded?.type === 'assistant/message' && recorded.data.message.source).toEqual({ kind: 'model', provider: 'mock', model: 'next-model', replayState, }) @@ -112,7 +112,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') || event.type === 'step/end' || event.type === 'turn/end') @@ -125,7 +125,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'wake') await idle - expect(agent.session.events + expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) @@ -175,7 +175,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() expect(events .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') @@ -200,10 +200,10 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start' + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start' || event.type === 'step/start' || event.type === 'turn/end').map(event => event.type)) .toEqual(['turn/start', 'turn/end']) - expect(agent.session.events.find(event => event.type === 'turn/end')?.data) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data) .toEqual({ turn: 1, reason: { kind: 'completed' } }) expect(agent.inbox.nextTurn).toHaveLength(0) }) @@ -244,16 +244,16 @@ describe('abort during tool execution ends the turn', () => { await started.promise await fiber.dispose() - expect(agent.session.events + expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) .toEqual([]) expect(agent.inbox.nextStep.map(inboxText)) .toEqual(['accepted result context during disposal']) - expect(agent.session.events.filter(event => event.type === 'turn/start')) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')) .toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) }) @@ -307,7 +307,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])[0]) @@ -359,7 +359,7 @@ describe('plugin exceptions are contained', () => { send(agent, 'first') await waitForIdle(ctx, agent) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } }, }) @@ -395,8 +395,8 @@ describe('disposal leaves the two-state status contract balanced', () => { expect(statuses).toEqual(['running', 'idle']) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - const messages = agent.session.events + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .flatMap(event => event.data.content) .flatMap(block => block.type === 'text' ? [block.text] : []) @@ -445,7 +445,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message : undefined).toContain('has no provider/model') @@ -507,7 +507,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { ['content', 'id', 'role', 'source'], ]) expect(targets).toEqual(['next-turn', 'next-step']) - const steeringSources = agent.session.events.flatMap(e => + const steeringSources = agent.session.snapshotEvents().flatMap(e => e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) @@ -540,7 +540,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = agent.session.events + const events = agent.session.snapshotEvents() const claims = events.flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step' && event.data.outcome !== 'canceled' @@ -583,7 +583,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) + const seeded = ctx2.sessions.create(SessionId('forked'), { seed: agent.session.snapshotEvents() }) const forked = new ReactLoopAgent( ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, ) @@ -652,7 +652,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () expect(errors[0]).toBeInstanceOf(LlmError) expect((errors[0] as LlmError).failure).toEqual(failure) - const events = [...agent.session.events] + const events = agent.session.snapshotEvents() const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) // A failed step must not synthesize an assistant message. @@ -674,7 +674,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }]) - expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) it('handles a finish error without a code (code key omitted)', async () => { @@ -696,7 +696,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () }) describe('step boundary publication order', () => { - it('the step/start event is in session.events when its session/event listener fires', async () => { + it('the step/start event is in session.snapshotEvents() when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) @@ -704,7 +704,7 @@ describe('step boundary publication order', () => { const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] ctx.on('session/event', (subject, event) => { if (subject !== agent.session || event.type !== 'step/start') return - const events = [...subject.events] + const events = subject.snapshotEvents() const last = events.at(-1) observed.push({ turn: event.data.turn, @@ -740,7 +740,7 @@ describe('turn and step boundary recovery', () => { /** Count turn/step boundary events for balance assertions. */ function boundaryCounts(agent: Agent) { - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() return { turnStart: e.filter(x => x.type === 'turn/start').length, turnEnd: e.filter(x => x.type === 'turn/end').length, @@ -770,7 +770,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) expect(errors).toEqual([]) @@ -803,7 +803,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'rejected') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'turn/start' + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start' || event.type === 'user/message')).toBe(false) expect(agent.inbox.nextTurn).toHaveLength(1) expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit']) @@ -834,7 +834,7 @@ describe('turn and step boundary recovery', () => { stepEnd: 0, errors: 1, }) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } }, }) }) @@ -927,7 +927,7 @@ describe('turn and step boundary recovery', () => { await fiber.dispose() // dispose during the hanging step await driverDone(agent) - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() const turnStarts = e.filter(x => x.type === 'turn/start').length const turnEnds = e.filter(x => x.type === 'turn/end').length expect(turnStarts).toBe(1) @@ -960,7 +960,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await agent.whenIdle() - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) @@ -989,12 +989,12 @@ describe('turn and step boundary recovery', () => { expect(errors).toEqual([]) // Session contains the observer failure per listener, so the committed turn // remains visible to later observers and executes normally. - const types = [...agent.session.events].map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'turn/start')).toHaveLength(1) expect(types.filter(t => t === 'turn/end')).toHaveLength(1) - const lastBoundary = [...agent.session.events].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end') + const lastBoundary = agent.session.snapshotEvents().findLast(e => e.type === 'turn/start' || e.type === 'turn/end') expect(lastBoundary?.type).toBe('turn/end') - expect(agent.session.events.at(-1)?.type).toBe('turn/end') + expect(agent.session.snapshotEvents().at(-1)?.type).toBe('turn/end') // loop survives: a second turn runs normally. send(agent, 'second') @@ -1026,7 +1026,7 @@ describe('turn and step boundary recovery', () => { .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') expect(stepEndIdx).toBeGreaterThanOrEqual(0) @@ -1060,7 +1060,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() // Both step/end and turn/end are present — finalization ran to completion. expect(e.some(x => x.type === 'step/end')).toBe(true) expect(e.some(x => x.type === 'turn/end')).toBe(true) @@ -1090,7 +1090,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) // turn 1 is balanced despite the throwing turn/end listener. - const e1 = [...agent.session.events] + const e1 = agent.session.snapshotEvents() expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1) expect(e1.at(-1)?.type).toBe('turn/end') @@ -1099,7 +1099,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'again') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - expect([...agent.session.events].filter(x => x.type === 'turn/end')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(x => x.type === 'turn/end')).toHaveLength(2) }) }) @@ -1131,7 +1131,7 @@ describe('tool result call identity', () => { await waitForIdle(ctx, agent) // The logged tool/result.callId is the originating call.id. - const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result') + const resultEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { expect(resultEvent.data.message.source.callId).toBe(ToolCallId('c1')) @@ -1195,7 +1195,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1243,7 +1243,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1293,7 +1293,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) // The post-listener cancellation check catches disposal before any step or LLM call. - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1340,7 +1340,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await fiber.dispose() await driverDone(agent) - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1385,7 +1385,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - const e = [...agent.session.events] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index d028451481..6c9ecc2020 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -69,13 +69,13 @@ describe('tool JSON parse', () => { await waitForIdle(ctx, agent) // tool/call event should have recorded the raw arguments string - const callEvent = agent.session.events.find(e => e.type === 'tool/call') + const callEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/call') expect(callEvent).toBeDefined() if (callEvent!.type === 'tool/call') { expect(callEvent!.data.arguments).toBe('not json') } // the loop did not crash — a result was produced - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) it('uses empty object when tool-call arguments are empty string', async () => { @@ -101,7 +101,7 @@ describe('tool JSON parse', () => { send(agent, 'use tool') await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) }) @@ -130,9 +130,9 @@ describe('thrown-value propagation', () => { expect(errors).toHaveLength(1) expect(errors[0]).toBe('naked string error') expect(adapter.requests).toHaveLength(0) - const starts = agent.session.events.filter(event => event.type === 'turn/start') - const ends = agent.session.events.filter(event => event.type === 'turn/end') - const messages = agent.session.events.filter(event => event.type === 'user/message') + const starts = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const ends = agent.session.snapshotEvents().filter(event => event.type === 'turn/end') + const messages = agent.session.snapshotEvents().filter(event => event.type === 'user/message') expect(starts).toHaveLength(0) expect(ends).toHaveLength(0) expect(messages).toHaveLength(0) @@ -155,7 +155,7 @@ describe('thrown-value propagation', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message : undefined).toBe('[object Object]') @@ -180,7 +180,7 @@ describe('durable error rendering', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { expect(turnEnd.data.reason.error).toEqual({ @@ -236,7 +236,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note, send(agent, 'go') await waitForIdle(ctx, agent) - const toolResult = agent.session.events.find(e => e.type === 'tool/result') + const toolResult = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(toolResult?.type === 'tool/result' && toolResult.data.message.content[0].isError).toBe(true) expect(toolResult?.type === 'tool/result' && toolResult.data.error) .toEqual({ name: 'HarnessError', code: 'BOOM' }) @@ -262,7 +262,7 @@ describe('request-error action edges', () => { // One failed request, no retry turn. expect(adapter.requests).toHaveLength(1) - const ends = agent.session.events.filter(e => e.type === 'turn/end') + const ends = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(ends).toHaveLength(1) }) @@ -284,7 +284,7 @@ describe('request-error action edges', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('aborted') }) }) @@ -313,7 +313,7 @@ describe('stream failure edges', () => { // No facts -> not offered to recovery; the turn fails through settle(). expect(recoveries).toBe(0) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) @@ -388,7 +388,7 @@ describe('tool result meta persistence', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const result = agent.session.events.find(e => e.type === 'tool/result') + const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.meta).toEqual({ presentation: 'diff-card' }) }) }) @@ -436,7 +436,7 @@ describe('recovery without a retry action', () => { expect(recoveries).toBe(1) expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) @@ -461,7 +461,7 @@ describe('unrenderable failure settlement', () => { send(agent, 'go') await agent.whenIdle() - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { // The durable failure keeps the adapter facts' message, not the @@ -502,7 +502,7 @@ describe('driver bookkeeping edges', () => { expect(proposals).toBe(2) expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(event => event.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' }) }) @@ -524,9 +524,9 @@ describe('driver bookkeeping edges', () => { send(agent, 'go') await agent.whenIdle() - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/end')).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 756d1d7ccc..d836d6e8f9 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -56,8 +56,8 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } describe('agent/pre-step', () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8f0df876c5..d3235a3970 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -45,7 +45,7 @@ function send(agent: Agent, text: string) { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -186,7 +186,7 @@ describe('agent loop', () => { expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(0) }) it('runs a simple turn: queued message → model → idle, with ordered events', async () => { @@ -209,13 +209,13 @@ describe('agent loop', () => { expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) // Durable inbox receipt precedes the turn-owned transcript. expect(types[0]).toBe('agent/inbox/spliced') expect(types).toContain('turn/start') expect(types).toContain('user/message') expect(types).toContain('assistant/message') - const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length }) expect(types.at(-1)).toBe('turn/end') @@ -257,7 +257,7 @@ describe('agent loop', () => { expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }]) // session log records call + result - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') }) @@ -319,7 +319,7 @@ describe('agent loop', () => { expect(errors.map(error => error.message)).toEqual([ 'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona")', ]) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message @@ -336,7 +336,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by DeepSeek Harness.\n\nIn /rescued.') - const turnEnds = agent.session.events.filter(e => e.type === 'turn/end') + const turnEnds = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(turnEnds).toHaveLength(2) expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed') }) @@ -396,7 +396,7 @@ describe('agent loop', () => { let mode = 'read-only' const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` }) const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' }) - const contextEvents = () => agent.session.events.flatMap(event => + const contextEvents = () => agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -437,7 +437,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(contextEvents()).toHaveLength(3) expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system)) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -449,7 +449,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt') @@ -464,7 +464,7 @@ describe('agent loop', () => { send(agent, 'after compaction') await waitForIdle(ctx, agent) - const runtimeContexts = agent.session.events.flatMap(event => + const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -484,7 +484,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt') @@ -544,7 +544,7 @@ describe('agent loop', () => { send(agent, 'repair context') await waitForIdle(ctx, agent) - const runtimeContexts = agent.session.events.flatMap(event => + const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -565,7 +565,7 @@ describe('agent loop', () => { send(agent, 'hi') await waitForIdle(ctx, agent) - const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk') + const chunkEvents = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk') // textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7 expect(chunkEvents).toHaveLength(7) // replay: chunk events alone re-assemble to the recorded assistant message @@ -599,13 +599,13 @@ describe('agent loop', () => { send(agent, 'start') await waitForIdle(ctx, agent) - const steering = agent.session.events.find(e => + const steering = agent.session.snapshotEvents().find(e => e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans')) expect(steering).toBeDefined() // The entered batch is appended after the second step opens and before its // request derives history. const steeringSeq = steering!.seq - const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1] + const secondStepStart = agent.session.snapshotEvents().filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq) @@ -623,12 +623,12 @@ describe('agent loop', () => { const idle = waitForIdle(ctx, agent) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })) expect(agent.status).toBe('running') - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })) await idle - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content)).toEqual([ [{ type: 'text', text: 'first idle steer' }], @@ -656,15 +656,15 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(1) expect(agent.inbox.nextStep).toHaveLength(1) send(agent, 'resume') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) @@ -676,8 +676,8 @@ describe('agent loop', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } })) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'agent/inbox/spliced', data: { target: 'next-step', @@ -691,7 +691,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) const flat = JSON.stringify(adapter.requests[0]!.messages) expect(flat).toContain('file changed: a.ts') expect(flat).not.toContain(' { send(agent, 'go') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(contextEvent?.type === 'user/message' && contextEvent.data.source) .toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) const requestText = JSON.stringify(adapter.requests[0]!.messages) @@ -732,7 +732,7 @@ describe('agent loop', () => { agent.inject(createUserMessage({ content: [first], source: { kind: 'plugin', plugin: 'x' } })) first.text = 'mutated after inject' agent.inject(createUserMessage({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'plugin', plugin: 'x' } })) - visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + visibleDuringTool = agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, })) @@ -744,10 +744,10 @@ describe('agent loop', () => { // The injection stays in the open turn, but its user-role context cannot // split the assistant tool call from the provider's tool-result message. - const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) - const result = agent.session.events.find(e => e.type === 'tool/result')! - const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result')! + const contexts = agent.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) expect(result.seq).toBeLessThan(contexts[0]!.seq) expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : [])) @@ -791,7 +791,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('agent/turn-stopping can steer another step (/loop pattern)', async () => { @@ -836,7 +836,7 @@ describe('agent loop', () => { // only one model call despite the tool call requesting a follow-up expect(adapter.requests).toHaveLength(1) // The tool still executes and durably records its result. - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) it('continues for steering that arrived during a concluding tool step', async () => { @@ -862,7 +862,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - const events = agent.session.events.map(event => event.type) + const events = agent.session.snapshotEvents().map(event => event.type) expect(events.filter(type => type === 'turn/end')).toHaveLength(1) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('late steering') const texts = adapter.requests[1]!.messages @@ -891,7 +891,7 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') // The header event records what the request ACTUALLY used — the switch is // a reconstructable fact, not silent drift. - const headerEvent = agent.session.events.find(e => e.type === 'request/header') + const headerEvent = agent.session.snapshotEvents().find(e => e.type === 'request/header') expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) @@ -930,7 +930,7 @@ describe('agent loop', () => { let boundaryOpen = true ctx.on('agent/pre-step', ({ agent: subject }, next) => { - if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' + if (subject === agent) boundaryOpen = subject.session.snapshotEvents().at(-1)?.type === 'step/start' return next() }) @@ -962,14 +962,14 @@ describe('agent loop', () => { // The first proposal failed inside a balanced turn without calling the model. expect(errors.map(error => error.message)).toEqual(['boom in pre-step']) expect(adapter.requests.length).toBe(0) - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) - expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/end')).toBe(true) // The loop survived: a second prompt runs a normal completed turn. send(agent, 'second') await waitForIdle(ctx, agent) expect(adapter.requests.length).toBe(1) - const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const lastTurnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' }) }) @@ -1007,7 +1007,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Assert the durable row, not only the live listener. - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' }) }) @@ -1111,7 +1111,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(executions).toBe(0) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) expect(agent.session.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, role: 'user', @@ -1121,7 +1121,7 @@ describe('agent loop', () => { expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Empty content still needs an assistant/message to carry usage; derivation // skips that host so it does not create a spurious assistant turn. - const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({ turn: 1, step: 1, @@ -1161,7 +1161,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ turn: 1, step: 1, @@ -1195,7 +1195,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'completed' }]) - const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ turn: 1, step: 1, @@ -1237,7 +1237,7 @@ describe('agent loop', () => { send(agent, 'continue') await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) // The follow-up request replays the truncated message with its replay // metadata pruned in step with the dropped tool call. expect(adapter.requests[1]?.messages[1]?.source).toEqual({ @@ -1305,7 +1305,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) @@ -1326,8 +1326,8 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - const turns = agent.session.events.filter(event => event.type === 'turn/start') - const messages = agent.session.events + const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) expect(turns).toHaveLength(1) @@ -1345,8 +1345,8 @@ describe('agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })) await idle - const turns = agent.session.events.filter(event => event.type === 'turn/start') - const sources = agent.session.events + const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const sources = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.source) expect(turns).toHaveLength(2) @@ -1402,10 +1402,10 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - const messages = agent.session.events + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(messages).toEqual([ [{ type: 'text', text: 'outer message' }], [{ type: 'text', text: 'model callback message' }], @@ -1435,7 +1435,7 @@ describe('agent loop', () => { }) expect(reasons[0]).toMatchObject({ kind: 'error' }) // The durable failure and live relay describe the same failed turn. - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' }) }) @@ -1488,7 +1488,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]?.reasoningEffort).toBe(effort) - const header = agent.session.events.find(event => event.type === 'request/header') + const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.reasoningEffort).toBe(effort) }) @@ -1526,11 +1526,11 @@ describe('agent loop', () => { send(agent, 'run') await waitForIdle(ctx, agent) - const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] }) + const replayed = ctx.sessions.create(SessionId('replayed'), { seed: agent.session.snapshotEvents() }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) // event-by-event identity of types over the inherited prefix - expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual( - agent.session.events.map(e => e.type)) - expect(replayed.events.at(-1)?.type).toBe('session/end-seed') + expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual( + agent.session.snapshotEvents().map(e => e.type)) + expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 38d135abb3..1dc37ffc14 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -72,26 +72,26 @@ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () } function userMessageTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) } function turnNumbers(agent: Agent): number[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'turn/start') .map(e => e.data.turn) } function turnEndNumbers(agent: Agent): number[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'turn/end') .map(e => (e.data as { turn: number }).turn) } function userMessageCountsByTurn(agent: Agent): number[] { const counts: number[] = [] - for (const event of agent.session.events) { + for (const event of agent.session.snapshotEvents()) { if (event.type === 'turn/start') counts.push(0) if (event.type === 'user/message') counts[counts.length - 1]! += 1 } diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 71633906b3..e8f0bab180 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -82,7 +82,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const usages = [...agent.session.events] + const usages = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/message') .map(e => e.data.usage) expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2 diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 2ee77022a5..17af91a06e 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -92,13 +92,13 @@ describe('agent/request-error', () => { code: 'SERVICE_UNAVAILABLE', }, ]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(seen.map(item => item.retryPolicy)).toEqual([ expect.objectContaining({ mode: 'normal' }), expect.objectContaining({ mode: 'normal' }), ]) expect(statuses).toEqual(['running', 'idle']) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -115,8 +115,8 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, }) @@ -137,8 +137,8 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 537ff03df9..d6332965ec 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -94,7 +94,7 @@ describe('request stability across the loop', () => { expect(Object.isFrozen(request.messages)).toBe(true) } // One anchoring header snapshot; no further header events (nothing changed). - const headerEvents = agent.session.events.filter(e => e.type === 'request/header') + const headerEvents = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(headerEvents).toHaveLength(1) expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial') }) @@ -111,7 +111,7 @@ describe('request stability across the loop', () => { expect(adapter.requests).toHaveLength(2) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -132,7 +132,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -157,7 +157,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => event.type === 'request/header' + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ { reason: 'initial', startsSeries: undefined }, @@ -194,7 +194,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -223,7 +223,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ReasoningEffortId('max'), ]) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([ ReasoningEffortId('high'), ReasoningEffortId('max'), @@ -242,7 +242,7 @@ describe('request stability across the loop', () => { const resumedCtx = await harness(resumedAdapter) const resumedHandle = await resumedCtx.agents.create({ sessionId: SessionId(`effort-${model}`), - seed: structuredClone(agent.session.events), + seed: structuredClone(agent.session.snapshotEvents()), agentOptions: { provider: 'mock', model }, }) send(resumedHandle.agent, 'resumed') @@ -250,7 +250,7 @@ describe('request stability across the loop', () => { expect(resumedAdapter.requests[0]?.model).toBe(model) expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(effort) - const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header') + const resumedHeaders = resumedHandle.agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(effort) expect(resumedHeaders.at(-1)?.data.reason).toBe('resume') } @@ -268,7 +268,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests[0]?.maxTokens).toBe(256_000) - const header = agent.session.events.find(event => event.type === 'request/header') + const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.maxTokens).toBe(256_000) expect(header?.type === 'request/header' && header.data.header.adapterDefaults) .toEqual({ maxTokens: true }) @@ -299,7 +299,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(256_000) expect(other.requests[0]?.maxTokens).toBe(8_192) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([256_000, 8_192]) expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([ { maxTokens: true }, @@ -333,7 +333,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(4_096) expect(other.requests[0]?.maxTokens).toBe(4_096) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([4_096, 4_096]) expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([undefined, undefined]) }) @@ -385,7 +385,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ]) expect(second.requests).toHaveLength(0) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high')) }) @@ -423,7 +423,7 @@ describe('request stability across the loop', () => { expect(signal.aborted).toBe(true) expect(handle.agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false) + expect(handle.agent.session.snapshotEvents().some(event => event.type === 'request/header')).toBe(false) }) it.each(['plain error', 'LLM error'] as const)( @@ -446,7 +446,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: failure instanceof LlmError ? { kind: 'error', error: failure.failure } @@ -519,7 +519,7 @@ describe('request stability across the loop', () => { const second = adapter.requests[1]! // The rewritten history: summary replaces turn 1's user+assistant pair. expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true) - expect(agent.session.events.flatMap(event => event.type === 'request/header' + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ { reason: 'initial', startsSeries: undefined }, @@ -557,7 +557,7 @@ describe('request stability across the loop', () => { expect(adapter.requests[1]?.messages[0]?.content).toContainEqual({ type: 'text', text: '[summary for retry]', }) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -570,14 +570,14 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' }) send(agent, 'third') await waitForIdle(ctx, agent) - const snapshots = agent.session.events.filter(e => e.type === 'request/header') + const snapshots = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('change') expect(adapter.requests[2]!.system).toContain('new guidance') @@ -604,7 +604,7 @@ describe('request stability across the loop', () => { const first = adapter.requests[0]! // The inject landed in the log after the boundary: not in THIS request… expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false) - expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') await waitForIdle(ctx, agent) @@ -631,7 +631,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } }) if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error() expect(turnEnd.data.reason.error.message).toMatch(/not extensible|frozen|read only|readonly/i) @@ -650,14 +650,14 @@ describe('request stability across the loop', () => { const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ sessionId: SessionId('gen2-session'), - seed: [...agent.session.events], + seed: agent.session.snapshotEvents(), agentOptions: { provider: 'mock', model: 'mock' }, }) const agent2 = handle.agent send(agent2, 'second') await waitForIdle(ctx2, agent2) - const snapshots = agent2.session.events.filter(e => e.type === 'request/header') + const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('resume') // Identical header across the restart: byte-identical continuation. @@ -689,7 +689,7 @@ describe('request stability across the loop', () => { // The second turn reuses the same series and header; the session's own // fold remains immutable state. - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) expect(Object.isFrozen(agent.session.requestHeader())).toBe(true) expect(adapter.requests[1]!.temperature).toBeUndefined() @@ -715,7 +715,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(3) - const events = agent.session.events + const events = agent.session.snapshotEvents() const stepStarts = events.filter(e => e.type === 'step/start') expect(stepStarts).toHaveLength(3) @@ -771,7 +771,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - const records = agent.session.events.filter(event => event.type === 'request/context') + const records = agent.session.snapshotEvents().filter(event => event.type === 'request/context') expect(records).toHaveLength(1) expect(records[0]?.data).toEqual({ provider: 'mock', model: 'mock', contextWindow: 128_000 }) // Log-only: not a SurfaceEventType, so it can never reach a model request @@ -796,7 +796,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data.contextWindow)).toEqual([64_000, 256_000]) }) @@ -808,7 +808,7 @@ describe('request/context capacity records', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }]) }) @@ -828,7 +828,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([ { provider: 'mock', model: 'known', contextWindow: 64_000 }, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index df2849d39d..320d5ce81f 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -159,7 +159,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', })) await waitForIdle(ctx, handle.agent) expect(handle.agent.session.deriveMessages()).toHaveLength(5) - expect(handle.agent.session.events.at(-1)).toMatchObject({ + expect(handle.agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -317,7 +317,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setup: async (agentCtx) => { expect(agentCtx.agent?.id).toBe(sessionId) // The two persisted events plus the end-seed marker. - expect(agentCtx.agent?.session.events).toHaveLength(3) + expect(agentCtx.agent?.session.snapshotEvents()).toHaveLength(3) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -650,7 +650,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) - const events1 = [...a1.session.events] + const events1 = a1.session.snapshotEvents() const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous await ctx1.fiber.dispose() @@ -672,18 +672,18 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') // …followed by one end-seed event marking the constructor seed. - expect(a2.session.events.length).toBe(events1.length + 1) + expect(a2.session.snapshotEvents().length).toBe(events1.length + 1) expect(a2.session.firstLiveSeq).toBe(events1.length) - expect(a2.session.events.at(-1)?.type).toBe('session/end-seed') + expect(a2.session.snapshotEvents().at(-1)?.type).toBe('session/end-seed') const replay = Session.create(SessionId('replay'), events1) expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) // …and a new turn continues numbering (turn 2) with contiguous seqs. a2.followup(createUserMessage({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } })) await waitForIdle(ctx2, a2) - const allSeqs = a2.session.events.map(e => e.seq) + const allSeqs = a2.session.snapshotEvents().map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates - const turnStarts = a2.session.events.filter(e => e.type === 'turn/start') + const turnStarts = a2.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2]) await ctx2.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 6b78213200..6d9e7b33e9 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -781,7 +781,7 @@ describe('agent scope lifecycle', () => { expect(statuses).toEqual([]) expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) - expect(announced.session.events).toEqual([]) + expect(announced.session.snapshotEvents()).toEqual([]) expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -1086,9 +1086,9 @@ describe('agent scope lifecycle', () => { // are empty and nothing still drives the detached session. expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.id)).toBeUndefined() - const eventsAfter = agent.session.events.length + const eventsAfter = agent.session.snapshotEvents().length await new Promise(resolve => setTimeout(resolve, 30)) - expect(agent.session.events.length).toBe(eventsAfter) + expect(agent.session.snapshotEvents().length).toBe(eventsAfter) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index e4d2c993e9..cc079ca291 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -41,8 +41,8 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** Build one assistant response containing the supplied tool calls. */ diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 563e5ba6c3..e7ffe77a5c 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -69,7 +69,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf describe('loop-level canonical tool order', () => { it('logs the request/header with tools in canonical order, not registration order', async () => { const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike']) - const header = foldRequestHeader(agent.session.events) + const header = foldRequestHeader(agent.session.snapshotEvents()) expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu']) // The dispatched request is built FROM the logged header (whose tools the // assembly already canonicalized) and reaches the adapter deep-frozen — @@ -82,14 +82,14 @@ describe('loop-level canonical tool order', () => { it('produces the same header order for any registration order', async () => { const first = await runTurn(['alpha', 'mike', 'zulu']) const second = await runTurn(['zulu', 'mike', 'alpha']) - const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name) + const names = (run: typeof first) => foldRequestHeader(run.agent.session.snapshotEvents())?.tools?.map(tool => tool.name) expect(names(first)).toEqual(['alpha', 'mike', 'zulu']) expect(names(second)).toEqual(names(first)) }) it('honors a configured toolOrder in the logged header and the dispatched request', async () => { const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST]) - const header = foldRequestHeader(agent.session.events) + const header = foldRequestHeader(agent.session.snapshotEvents()) expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike']) expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike']) expect(Object.isFrozen(adapter.requests[0])).toBe(true) @@ -103,10 +103,10 @@ describe('loop-level canonical tool order', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(foldRequestHeader(agent.session.events)).toBeUndefined() - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(true) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) - expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false) - expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false) + expect(foldRequestHeader(agent.session.snapshotEvents())).toBeUndefined() + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'step/start')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'step/end')).toBe(false) }) }) diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 3644b30b30..9413b1d4d1 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 05795d5ca3..483d8c9eba 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index c6b9204c92..fccfdcba64 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -29,7 +29,7 @@ export class Inbox { private readonly session: Session, private readonly notifications: InboxNotifications, ) { - for (const event of session.events.slice(session.header.seedLength ?? 0)) { + for (const event of session.snapshotEvents(session.header.seedLength ?? 0)) { if (event.type !== 'agent/inbox/spliced') continue try { this.apply(event.data) diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 33ecfb3c7b..e1da38b652 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -124,13 +124,13 @@ describe('Inbox', () => { const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) inbox.append('next-turn', nextTurn) inbox.append('next-step', nextStep) - const beforeClear = session.events.length + const beforeClear = session.snapshotEvents().length inbox.clear() expect(inbox.hasPending).toBe(false) expect(discarded).toEqual([nextStep, nextTurn]) - expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' + expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' ? event.data : event.type)).toEqual([ { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, @@ -138,7 +138,7 @@ describe('Inbox', () => { ]) inbox.clear() - expect(session.events).toHaveLength(beforeClear + 2) + expect(session.snapshotEvents()).toHaveLength(beforeClear + 2) }) }) diff --git a/packages/core/agent/tests/consumed-work.spec.ts b/packages/core/agent/tests/consumed-work.spec.ts index 3b79d67620..26808c5c81 100644 --- a/packages/core/agent/tests/consumed-work.spec.ts +++ b/packages/core/agent/tests/consumed-work.spec.ts @@ -40,7 +40,7 @@ describe('foldConsumedWork', () => { const session = Session.create(SessionId('empty')) accept(session, 'queued') - expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false }) + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ droppedUnrun: false }) }) it('reports the latest turn that entered a model step', () => { @@ -48,7 +48,7 @@ describe('foldConsumedWork', () => { steppedTurn(session, 1, { kind: 'completed' }) steppedTurn(session, 2, { kind: 'max-tokens' }) - expect(foldConsumedWork(session.events).end?.data) + expect(foldConsumedWork(session.snapshotEvents()).end?.data) .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) }) @@ -61,7 +61,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('reports a turn that claimed its input and was then stopped before any step', () => { @@ -71,7 +71,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('ignores a turn stopped, failed, or rejected without taking any input', () => { @@ -85,7 +85,7 @@ describe('foldConsumedWork', () => { session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } }) // None of these turns describes work: they opened, found nothing of their own, and closed. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('reports a turn whose claimed input a pre-step rejection discarded', () => { @@ -97,7 +97,7 @@ describe('foldConsumedWork', () => { // Rejection does not retain the claimed messages, so the `blocked` end is // the only account of input that will never run. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('ignores a claim its own turn emptied', () => { @@ -109,7 +109,7 @@ describe('foldConsumedWork', () => { // An emptied claim ran nothing and dropped nothing: a listener rewrote the // batch away, which is not this log's account of the work. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('credits a claim with no open turn to no turn at all', () => { @@ -120,7 +120,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('reports work cancelled out of the inbox after the last accounting turn', () => { @@ -130,8 +130,8 @@ describe('foldConsumedWork', () => { cancelPending(session) // No turn opened over it, so only the cancellation says the work was cut short. - expect(foldConsumedWork(session.events)).toEqual({ - end: session.events.find(event => event.type === 'turn/end'), + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + end: session.snapshotEvents().find(event => event.type === 'turn/end'), droppedUnrun: true, }) }) @@ -143,7 +143,7 @@ describe('foldConsumedWork', () => { target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled', }) - expect(foldConsumedWork(session.events).droppedUnrun).toBe(false) + expect(foldConsumedWork(session.snapshotEvents()).droppedUnrun).toBe(false) }) it('lets a later accounting turn absorb an earlier drop', () => { @@ -152,8 +152,8 @@ describe('foldConsumedWork', () => { cancelPending(session) steppedTurn(session, 2, { kind: 'completed' }) - expect(foldConsumedWork(session.events)).toEqual({ - end: session.events.findLast(event => event.type === 'turn/end'), + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + end: session.snapshotEvents().findLast(event => event.type === 'turn/end'), droppedUnrun: false, }) }) diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 5bff0c4849..a754c15c34 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 99d73bedc2..5475521b65 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 0d691b31c4918152ecf1092002646296c5d9984a -README.zh.md: 2118def74c059f6637f12a9aeba20ff92a5d2363 +README.md: fde5d3846ef36dff56b7656068603ae9dd148fcf +README.zh.md: 78c52323fe6a541b2d73b55195aaf7b4274b1411 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0d691b31c4..fde5d3846e 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,6 +49,10 @@ session.deriveMessages() // the derived model history Surface events (`user/message`, `assistant/message`, `tool/result`) must declare how they join the ordered surface; raw chunks, boundaries, and other log-only events never produce a message. +### Read the log + +`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`. + ### Fork a session `ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 2118def74c..78c52323fe 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -49,6 +49,10 @@ session.deriveMessages() // the derived model history 表层事件(`user/message`、`assistant/message`、`tool/result`)必须声明如何进入有序 surface;原始分片、边界与其他仅日志事件从不产生消息。 +### 读取日志 + +`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 + ### 派生会话的 fork `ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。 diff --git a/packages/core/session/package.json b/packages/core/session/package.json index e8b1b08a55..3e4be78030 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/src/chunk-rows.ts b/packages/core/session/src/chunk-rows.ts index 51a65a1687..0feda80880 100644 --- a/packages/core/session/src/chunk-rows.ts +++ b/packages/core/session/src/chunk-rows.ts @@ -7,7 +7,7 @@ * `tool-call-chunks` — and expands rows back to the exact original events. * * Packed rows are an encoding vocabulary, NOT session events: they never enter - * `Session.events`, have no `SessionEventMap` entry, and use bare (slash-less) + * `Session.snapshotEvents()`, have no `SessionEventMap` entry, and use bare (slash-less) * type tags so a reader cannot confuse them with the event taxonomy * (precedent: the JSONL header line's `session` tag). Persistence and bounded * history transport both use the codec. The encoder whitelists exact shapes — diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index afa2ff3604..bf08a17535 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -545,18 +545,32 @@ export class Session { } } - /** Cached immutable public snapshot of the private append-only log. */ + /** Cached immutable full snapshot of the private append-only log. */ private eventsSnapshot: readonly SessionEvent[] | undefined /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[] { - this.eventsSnapshot ??= Object.freeze([...this.log]) - return this.eventsSnapshot + eventAt(seq: number): SessionEvent | undefined { + return this.log[seq] + } + + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[] { + if (fromSeq === 0 && toSeqExclusive === this.log.length) { + this.eventsSnapshot ??= Object.freeze([...this.log]) + return this.eventsSnapshot + } + return Object.freeze(this.log.slice(fromSeq, toSeqExclusive)) } /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ @@ -661,7 +675,7 @@ export class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -1092,9 +1106,8 @@ export class SessionStore extends Service { }) } - private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] { - const events = session.events - const lastEvent = events.at(-1) + private _forkSeed(session: Session, requestedBoundary: number | undefined): readonly SessionEvent[] { + const lastEvent = session.eventAt(session.seq - 1) let boundary: number if (requestedBoundary !== undefined) { boundary = requestedBoundary @@ -1108,22 +1121,23 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } - if (boundary >= events.length) { - const lastSeq = events.at(-1)?.seq + if (boundary >= session.seq) { + const lastSeq = lastEvent?.seq throw new SessionForkError( `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`, 'INVALID_BOUNDARY', ) } - const boundaryEvent = events[boundary] + const boundaryEvent = session.eventAt(boundary) if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) { throw new SessionForkError( `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, 'INVALID_BOUNDARY', ) } - const lastTurnBoundary = events.slice(0, boundary + 1) + const events = session.snapshotEvents(0, boundary + 1) + const lastTurnBoundary = events .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') if (lastTurnBoundary?.type === 'turn/start') { throw new SessionForkError( @@ -1132,7 +1146,7 @@ export class SessionStore extends Service { ) } - return events.slice(0, boundary + 1) + return events } private _resolveForkSource(source: SessionForkSource): Session { diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 31b82ba96f..ca95319afe 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -206,7 +206,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seedSession = (session: Session): SessionTrace => { const trace = freshTrace() traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { applyTransition(trace, validateEvent(trace, event, fail)) } return trace diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index f93c898375..c2f11e3572 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,7 +16,7 @@ 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 Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages() + return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), session.snapshotEvents()).deriveMessages() } describe('derived-message cache', () => { diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 6a7770b4b6..537bf9c3ee 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -47,14 +47,14 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m } function lastSeq(session: Session): number { - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) if (event === undefined) throw new Error('missing last event') 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 events = session.snapshotEvents() 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) @@ -83,19 +83,19 @@ describe('SessionStore.fork', () => { const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) - expect(inherited(child)).toEqual(source.events) - expect(child.events).not.toBe(source.events) - expect(child.events[1]).not.toBe(source.events[1]) + expect(inherited(child)).toEqual(source.snapshotEvents()) + expect(child.snapshotEvents()).not.toBe(source.snapshotEvents()) + expect(child.snapshotEvents()[1]).not.toBe(source.snapshotEvents()[1]) expect(() => { - firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } + firstUserMessage(child.snapshotEvents()).data.content[0] = { type: 'text', text: 'child mutation' } }).toThrow(TypeError) - expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(firstUserMessage(source.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(firstUserMessage(child.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(child.header).toMatchObject({ id: SessionId('child'), cwd: '/workspace', parentSession: SessionId('parent'), - seedLength: source.events.length, + seedLength: source.snapshotEvents().length, }) }) @@ -107,7 +107,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('log-only-child')) - expect(inherited(child)).toEqual(source.events) + expect(inherited(child)).toEqual(source.snapshotEvents()) expect(inherited(child).at(-1)).toMatchObject({ type: 'test/log-only', data: { value: 'after execution' }, @@ -124,7 +124,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) - expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1)) + expect(inherited(child)).toEqual(source.snapshotEvents().slice(0, firstBoundary + 1)) expect(child.header.seedLength).toBe(firstBoundary + 1) expect(child.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, @@ -152,7 +152,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, lastSeq(source), SessionId(`child-${index}`)) expect(inherited(child).at(-1)?.type).toBe('turn/end') - expect(child.header.seedLength).toBe(source.events.length) + expect(child.header.seedLength).toBe(source.snapshotEvents().length) } }) @@ -167,10 +167,10 @@ describe('SessionStore.fork', () => { 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) + expect(parent.snapshotEvents().at(-1)).toBe(open) + expect(parent.snapshotEvents().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) + const boundary = child.snapshotEvents().at(-1) expect(boundary).toMatchObject({ type: 'session/end-seed' }) expect(boundary!.seq).toBeGreaterThan(open.seq) expect(child.firstLiveSeq).toBe(open.seq + 1) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 0b9a282d45..26aede8930 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -79,7 +79,7 @@ describe('session-log invariants', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('later dispatch veto') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(() => { session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -413,7 +413,7 @@ describe('session-log invariants', () => { 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']) + expect(open.snapshotEvents().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/) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index e27c0c8d44..4eef9e7283 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -102,7 +102,7 @@ describe('Session properties', () => { it('seq is strictly monotonic and zero-based contiguous', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) - session.events.forEach((event, i) => { expect(event.seq).toBe(i) }) + session.snapshotEvents().forEach((event, i) => { expect(event.seq).toBe(i) }) expect(session.seq).toBe(events.length) })) }) @@ -110,10 +110,10 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events]) + const replayed = Session.create(SessionId(`replay-${counter++}`), original.snapshotEvents()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // Every explicit replay grows by exactly one log-only boundary. - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) })) }) @@ -121,10 +121,10 @@ describe('Session properties', () => { 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]) + const once = Session.create(SessionId(`idem-a-${counter++}`), original.snapshotEvents()) + const twice = Session.create(SessionId(`idem-b-${counter++}`), once.snapshotEvents()) // Lazy resume makes browsing a pickup, so this must not grow per open. - expect(twice.events).toEqual(once.events) + expect(twice.snapshotEvents()).toEqual(once.snapshotEvents()) })) }) @@ -157,7 +157,7 @@ describe('Session properties', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) const messages = session.deriveMessages() - const before = structuredClone(session.events) + const before = structuredClone(session.snapshotEvents()) for (const m of messages) { expect(['user', 'assistant', 'system']).toContain(m.role) // Derived messages are frozen shared projections: mutation THROWS @@ -165,7 +165,7 @@ describe('Session properties', () => { expect(Object.isFrozen(m)).toBe(true) expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError) } - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) })) }) }) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 764b615877..17d7ba80de 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -82,7 +82,7 @@ describe('foldRequestHeader', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('request/header', { header: { config: { provider: 'mock', model: 'other' }, tools: [] }, reason: 'change' }) - expect(foldRequestHeader(session.events)).toEqual({ config: { provider: 'mock', model: 'other' } }) + expect(foldRequestHeader(session.snapshotEvents())).toEqual({ config: { provider: 'mock', model: 'other' } }) }) }) @@ -97,7 +97,7 @@ describe('legacy request-header format', () => { 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/) - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('rejects the removed fallback reason in seeds and untyped appends', () => { @@ -111,7 +111,7 @@ describe('legacy request-header format', () => { 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) + expect(session.snapshotEvents()).toHaveLength(0) }) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 49c4e3f662..09badf180f 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -65,7 +65,7 @@ describe('Session', () => { 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')! + const turnEnd = session.snapshotEvents().findLast(e => e.type === 'turn/end')! expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' }) // survives a structuredClone (the persistence-serialization boundary) expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) @@ -75,9 +75,9 @@ describe('Session', () => { 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') + const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.snapshotEvents())) + expect(replayed.snapshotEvents().slice(0, -1)).toEqual(session.snapshotEvents()) + const turnEnd = replayed.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -109,7 +109,7 @@ describe('Session', () => { session.append('user/message', message, { surfaceOp: 'append' }) expect(session.deriveMessages()).toEqual([message]) - const event = session.events[0] + const event = session.snapshotEvents()[0] expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) }) @@ -132,27 +132,27 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('s3-replay'), [...original.events]) + const replayed = Session.create(SessionId('s3-replay'), original.snapshotEvents()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // The seed verbatim, plus the end-seed event the constructor appends. - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) 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([]) + expect(fresh.snapshotEvents()).toEqual([]) const resumed = Session.create(SessionId('resumed-empty'), []) expect(resumed.firstLiveSeq).toBe(0) - expect(resumed.events).toMatchObject([ + expect(resumed.snapshotEvents()).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, ]) - const reopened = Session.create(SessionId('reopened-empty'), resumed.events) + const reopened = Session.create(SessionId('reopened-empty'), resumed.snapshotEvents()) expect(reopened.firstLiveSeq).toBe(1) - expect(reopened.events).toEqual(resumed.events) + expect(reopened.snapshotEvents()).toEqual(resumed.snapshotEvents()) }) it('rejects pre-provider request headers and assistant messages on seed/load', () => { @@ -181,7 +181,7 @@ describe('Session', () => { const unrelatedPrimitiveData = { type: 'plugin/event', seq: 0, time: 1, data: null, } as unknown as SessionEvent - expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1)) + expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).snapshotEvents().slice(0, 1)) .toEqual([unrelatedPrimitiveData]) }) @@ -387,7 +387,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0]) + expect(Session.create(SessionId('reasoning-effort'), [valid]).snapshotEvents()[0]) .toEqual(valid) for (const reasoningEffort of ['', 1]) { @@ -417,7 +417,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid) + expect(Session.create(SessionId('adapter-defaults'), [valid]).snapshotEvents()[0]).toEqual(valid) for (const adapterDefaults of [ null, @@ -447,7 +447,7 @@ describe('Session', () => { isError: false, }), }, { surfaceOp: 'append' }) - const before = structuredClone(session.events) + const before = structuredClone(session.snapshotEvents()) // A misbehaving consumer tries to mutate the messages it was handed. const messages = session.deriveMessages() @@ -463,7 +463,7 @@ describe('Session', () => { messages.reverse() // The log is unchanged: deep-equal to the snapshot taken before mutation. - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) // And a fresh derivation still reflects the original content and order. expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }]) }) @@ -492,7 +492,7 @@ describe('Session', () => { cyclic['self'] = cyclic expect(bad(cyclic)).toThrow(/non-JSON-serializable/) // The rejected appends never entered the log. - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { @@ -506,13 +506,13 @@ describe('Session', () => { }))) .toThrow(/surface-eligible and requires a surfaceOp marker/) // The rejected append never entered the log (only turn/start is present). - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) it('accepts dense arrays and nested plain objects', () => { 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) + expect(session.snapshotEvents()).toHaveLength(1) }) it('validates seed events: rejects a non-JSON-serializable seed', () => { @@ -556,7 +556,7 @@ describe('Session', () => { { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] const session = Session.create(SessionId('seed-ok'), goodSeed) - expect(session.events.slice(0, 3)).toEqual(goodSeed) + expect(session.snapshotEvents().slice(0, 3)).toEqual(goodSeed) expect(session.firstLiveSeq).toBe(3) }) @@ -581,7 +581,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-entry-snapshot'), seed) expect(reads).toBe(1) - expect(session.events.slice(0, 1)).toEqual([accepted]) + expect(session.snapshotEvents().slice(0, 1)).toEqual([accepted]) }) it('reads a nested seed-data getter once and stores its first JSON value', () => { @@ -598,7 +598,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-nested-drift'), seed) expect(reads).toBe(1) - expect(session.events[0]!.data).toEqual({ value: 'accepted' }) + expect(session.snapshotEvents()[0]!.data).toEqual({ value: 'accepted' }) }) it('rejects non-JSON surface metadata in a seed event', () => { @@ -659,7 +659,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-null-prototype'), [event]) - expect(session.events.slice(0, 1)).toEqual([{ ...event }]) + expect(session.snapshotEvents().slice(0, 1)).toEqual([{ ...event }]) }) it('reads a nested seed-metadata getter once and stores its first JSON value', () => { @@ -691,7 +691,7 @@ describe('Session', () => { }] as unknown as SessionEvent[] const session = Session.create(SessionId('seed-unstable-metadata'), seed) - const event = session.events[1]! + const event = session.snapshotEvents()[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') expect(reads).toBe(1) @@ -734,7 +734,7 @@ describe('Session', () => { } }) - it('snapshots the seed: mutating the original after construction does not affect session.events', () => { + it('snapshots the seed: mutating the original after construction does not affect session.snapshotEvents()', () => { const seed = [ { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: { @@ -747,16 +747,16 @@ describe('Session', () => { 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. + // data past validation). The snapshot must shield session.snapshotEvents(). const um = seed[1]! ;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' ;(um.data as Record)['injected'] = 1n // would have failed validation - const logged = session.events[1]! + const logged = session.snapshotEvents()[1]! expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') expect((logged.data as Record)['injected']).toBeUndefined() }) - it('snapshots append data: mutating the passed object after append does not affect session.events', () => { + it('snapshots append data: mutating the passed object after append does not affect session.snapshotEvents()', () => { const session = Session.create(SessionId('append-snapshot')) const data = { id: MessageId('append-input'), @@ -766,10 +766,10 @@ describe('Session', () => { } const event = session.append('user/message', data, { surfaceOp: 'append' }) // Mutate the caller's object after append returns. A shared reference would - // make session.events diverge from the value that passed validation. + // make session.snapshotEvents() diverge from the value that passed validation. data.content[0]!.text = 'HACKED' ;(data as Record)['injected'] = 1n - const logged = session.events[0]! + const logged = session.snapshotEvents()[0]! expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') expect((logged.data as Record)['injected']).toBeUndefined() // The returned event carries the same snapshot, not the caller's input. @@ -791,7 +791,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.data).toEqual({ value: 'accepted' }) - expect(session.events).toEqual([event]) + expect(session.snapshotEvents()).toEqual([event]) }) it('rejects non-JSON surface metadata before appending the event', () => { @@ -804,7 +804,7 @@ describe('Session', () => { }), { surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never, )).toThrow(/non-JSON-serializable surface metadata/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('rejects exotic surface metadata before cloning can erase its prototype', () => { @@ -822,7 +822,7 @@ describe('Session', () => { }), { surfaceOp: new ReplaceOp() }, )).toThrow(/non-JSON-serializable surface metadata/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('reads a nested append-metadata getter once and stores its first JSON value', () => { @@ -853,7 +853,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) - expect(session.events).toEqual([source, event]) + expect(session.snapshotEvents()).toEqual([source, event]) }) it('rejects invalid plain surface metadata shapes at append', () => { @@ -874,7 +874,7 @@ describe('Session', () => { surfaceOp: 'append', sourceEventSeqs: [0, -1], })).toThrow(/non-negative safe integers/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('rejects surface metadata on non-surface append and seed events', () => { @@ -897,7 +897,7 @@ describe('Session', () => { data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('deep-freezes seeded and appended event snapshots', () => { @@ -907,7 +907,7 @@ describe('Session', () => { time: 1, data: { turn: 1 }, }]) - const seededEvent = seeded.events[0]! + const seededEvent = seeded.snapshotEvents()[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) @@ -957,21 +957,38 @@ describe('Session', () => { it('returns cached frozen event-array snapshots that do not grow after append', () => { const session = Session.create(SessionId('events-snapshot')) session.append('turn/start', { turn: 1 }) - const before = session.events + const before = session.snapshotEvents() const beforeEvent = before[0]! if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') - expect(session.events).toBe(before) + expect(session.snapshotEvents()).toBe(before) expect(Object.isFrozen(before)).toBe(true) expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError) expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const after = session.events + const after = session.snapshotEvents() expect(before).toHaveLength(1) expect(after).toHaveLength(2) expect(after).not.toBe(before) - expect(session.events).toBe(after) + expect(session.snapshotEvents()).toBe(after) + }) + + it('reads one event without materializing an array and snapshots half-open ranges', () => { + const session = Session.create(SessionId('event-reads')) + const start = session.append('turn/start', { turn: 1 }) + const end = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + expect(session.eventAt(0)).toBe(start) + expect(session.eventAt(1)).toBe(end) + expect(session.eventAt(2)).toBeUndefined() + + const range = session.snapshotEvents(1, 2) + expect(range).toEqual([end]) + expect(Object.isFrozen(range)).toBe(true) + + session.append('turn/start', { turn: 2 }) + expect(range).toEqual([end]) }) it('detaches and freezes an explicitly supplied session header', () => { @@ -1086,7 +1103,7 @@ describe('Session', () => { const marked = Session.create(SessionId('ignorable-envelope'), [ { ...base, ignorable: true } as SessionEvent, ]) - expect(marked.events[0]?.ignorable).toBe(true) + expect(marked.snapshotEvents()[0]?.ignorable).toBe(true) }) }) @@ -1130,7 +1147,7 @@ describe('SessionStore', () => { a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) + const forked = ctx.sessions.create(SessionId('fork'), { seed: a.snapshotEvents() }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -1394,7 +1411,7 @@ describe('SessionStore', () => { const heard: SessionEvent[] = [] let committedBeforeNotify = false ctx.on('session/event', (observedSession, event) => { - committedBeforeNotify = observedSession.events.at(-1) === event + committedBeforeNotify = observedSession.snapshotEvents().at(-1) === event throw new Error('sync event observer') }) ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never) @@ -1407,7 +1424,7 @@ describe('SessionStore', () => { }) }).not.toThrow() expect(committedBeforeNotify).toBe(true) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) await Promise.resolve() await Promise.resolve() @@ -1430,7 +1447,7 @@ describe('SessionStore', () => { const [observedSession, event] = args as [Session, SessionEvent] validations.push({ event, - logLength: observedSession.events.length, + logLength: observedSession.snapshotEvents().length, frozen: Object.isFrozen(event) && Object.isFrozen(event.data), }) if (reject) { @@ -1443,7 +1460,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('reject first candidate') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) const appended = session.append('turn/start', { @@ -1455,7 +1472,7 @@ describe('SessionStore', () => { ]) expect(validations.map(({ event }) => event.seq)).toEqual([0, 0]) expect(validations[1]!.event).toBe(appended) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(observed).toEqual([appended]) }) @@ -1494,7 +1511,7 @@ describe('SessionStore', () => { sourceEventSeqs: [2], })).toThrow('reject surface candidate') - expect(session.events).toHaveLength(3) + expect(session.snapshotEvents()).toHaveLength(3) expect(surface.nodes).toEqual([2]) expect(surface.replaceGeneration).toBe(0) @@ -1519,7 +1536,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('dispatch instrumentation rejected the carrier') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) }) @@ -1538,7 +1555,7 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, }) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) expect(warnings).toEqual([ 'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published', @@ -1569,7 +1586,7 @@ describe('SessionStore', () => { turn: 1, }) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached']) expect(ctx.sessions.get(session.id)).toBeUndefined() }) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 4be69465c3..3a92246a85 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -318,7 +318,7 @@ describe('SurfaceManager', () => { }), }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] }) - const folded = foldSurface(s.events) + const folded = foldSurface(s.snapshotEvents()) expect(folded.nodes).toEqual(s.surface.nodes) expect(folded.replacements).toEqual([ { seq: 2, start: 0, end: 0, shadowedSeqs: [0] }, @@ -327,8 +327,8 @@ describe('SurfaceManager', () => { folded.nodes[0] = 99 folded.replacements[0]!.shadowedSeqs.push(99) expect(s.surface.nodes).toEqual([3]) - expect(foldSurface(s.events).nodes).toEqual([3]) - expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0]) + expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3]) + expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0]) }) it('does not retain fold-only replacement history in incremental state', () => { @@ -351,7 +351,7 @@ describe('SurfaceManager', () => { expect(s.surface.nodes).toEqual([1]) const manager = s.surface as unknown as { _state: object } expect(Object.hasOwn(manager._state, 'replacements')).toBe(false) - expect(foldSurface(s.events).replacements).toEqual([ + expect(foldSurface(s.snapshotEvents()).replacements).toEqual([ { seq: 1, start: 0, end: 0, shadowedSeqs: [0] }, ]) }) @@ -375,7 +375,7 @@ describe('SurfaceManager', () => { const surface = s.surface const nodes = surface.nodes - expect(nodes).toEqual(foldSurface(s.events).nodes) + expect(nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) expect(surface.replaceGeneration).toBe(0) expect(() => s.append( @@ -394,11 +394,11 @@ describe('SurfaceManager', () => { { surfaceOp: { op: 'replace', start: 0, end: 0 } }, )).toThrow(/missing 0/) - expect(s.events).toHaveLength(1) + expect(s.snapshotEvents()).toHaveLength(1) expect(s.surface).toBe(surface) expect(surface.nodes).toEqual([0]) expect(surface.replaceGeneration).toBe(0) - expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'b' }], source: { kind: 'user' }, @@ -406,7 +406,7 @@ describe('SurfaceManager', () => { expect(surface.nodes).toBe(nodes) expect(surface.nodes).toEqual([0, 1]) expect(surface.replaceGeneration).toBe(0) - expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) }) it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { @@ -479,7 +479,7 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) - const replayed = Session.create(SessionId('replay'), [...original.events]) + const replayed = Session.create(SessionId('replay'), original.snapshotEvents()) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) }) @@ -645,7 +645,7 @@ describe('SurfaceManager', () => { // Mutate caller's array after append. sources.push(1) sources[0] = 99 - const logged = s.events[1]! as SurfaceEvent + const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.sourceEventSeqs).toEqual([0]) }) @@ -697,7 +697,7 @@ describe('SurfaceManager', () => { }, { surfaceOp: op, sourceEventSeqs: [0] }) // Mutate caller's object after append. op.start = 99 - const logged = s.events[1]! as SurfaceEvent + const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) }) }) @@ -797,8 +797,8 @@ describe('Session.append surface opts', () => { expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. - expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) - expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append') + expect((s.snapshotEvents()[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) + expect((s.snapshotEvents()[2]! as SurfaceEvent).surfaceOp).toBe('append') }) it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => { @@ -830,8 +830,8 @@ describe('Session.append surface opts', () => { it('a non-surface event carries no surface fields', () => { const s = Session.create(SessionId('noopts')) s.append('turn/start', { turn: 1 }) - expect((s.events[0] as SessionEvent).sourceEventSeqs).toBeUndefined() - expect((s.events[0] as SessionEvent).surfaceOp).toBeUndefined() + expect((s.snapshotEvents()[0] as SessionEvent).sourceEventSeqs).toBeUndefined() + expect((s.snapshotEvents()[0] as SessionEvent).surfaceOp).toBeUndefined() }) it('surfaceOp primitives are not cloned (they are immutable)', () => { @@ -882,13 +882,13 @@ describe('surface type guards', () => { it('isSurfaceEvent narrows a fully-formed surface event', () => { const s = surfaceSession() - const userMessage = s.events.find(e => e.type === 'user/message')! + const userMessage = s.snapshotEvents().find(e => e.type === 'user/message')! expect(isSurfaceEvent(userMessage)).toBe(true) }) it('isSurfaceEvent rejects a non-surface-eligible type', () => { const s = surfaceSession() - const turnStart = s.events.find(e => e.type === 'turn/start')! + const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')! expect(isSurfaceEvent(turnStart)).toBe(false) }) @@ -913,8 +913,8 @@ describe('surface type guards', () => { 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)! + const appended = s.snapshotEvents().find(e => e.type === 'user/message')! + const replacement = s.snapshotEvents().at(-1)! expect(isAppendSurfaceEvent(appended)).toBe(true) expect(isReplacementSurfaceEvent(appended)).toBe(false) @@ -924,7 +924,7 @@ describe('surface type guards', () => { it('rejects log-only and markerless events from both marker guards', () => { const s = surfaceSession() - const turnStart = s.events.find(e => e.type === 'turn/start')! + const turnStart = s.snapshotEvents().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 = { diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index b0f34fb471..0844250704 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 97126c55a2..7a54429077 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 5489f61d80..539fc43373 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -58,7 +58,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): number | null => { let openTurn: number | null = null dispatchRoots.set(session, new Map()) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateDispatch(session, event) commitDispatch(session, event) if (event.type === 'turn/start') openTurn = event.data.turn diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index db3c0b905c..c68ae14073 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -152,7 +152,7 @@ describe('tool-pipeline invariants', () => { name: 'echo', arguments: {}, })).toThrow(/parentCallId child does not belong to rootCallId another-root/) - expect(session.events.some(event => event.type === 'tool/code-dispatch-start' + expect(session.snapshotEvents().some(event => event.type === 'tool/code-dispatch-start' && String(event.data.subCallId) === 'invalid-grandchild')).toBe(false) }) diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index ce435c12ef..da0be57a2a 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -1484,7 +1484,7 @@ describe('the run_code dispatch bridge', () => { expect(result.isError).toBe(false) expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth }) expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' }) - const dispatch = session.events.find(event => event.type === 'tool/code-dispatch') + const dispatch = session.snapshotEvents().find(event => event.type === 'tool/code-dispatch') if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event') const logged = dispatch.data.arguments as { nested: JsonValue } let loggedDepth = 0 diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 8df05bbaaf..42addb36fe 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 6b86a2e189..399fc86da1 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index f314bb2651..ed04cd225d 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 424b72f0ac..18cb197d4c 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 142be2c67f..35293a1718 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index cc2855eecf..9d9079acd9 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index da942d32aa..c65ec96d25 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Private profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index 8e549ee207..1e37547f07 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Private Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 057884d64f..8d083c2723 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index 80a384727e..3ec610e199 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -306,7 +306,7 @@ export class TeamMailbox { /** Whether a target Session already contains the durable message identity. */ private targetRecorded(session: Session, messageId: TeamMessageId): boolean { - const suffix = session.events.slice(session.header.seedLength ?? 0) + const suffix = session.snapshotEvents(session.header.seedLength ?? 0) return messageAccepted(suffix, message => message.source.kind === 'team-message' && message.source.messageId === messageId) } diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index 87bf039034..802d0b4a2d 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -374,7 +374,7 @@ export class TeamRoster { try { signal.throwIfAborted() await this.ctx.sessions.flush(session) - const suffix = session.events.slice(session.header.seedLength ?? 0) + const suffix = session.snapshotEvents(session.header.seedLength ?? 0) if (messageAccepted(suffix, message => message.id === messageId)) return if (this.ctx.sessions.get(childId) !== session) continue await progress.promise @@ -481,6 +481,6 @@ export class TeamRoster { /** Whether a Session's own suffix identifies a provider-owned subagent child. */ private subagentDescriptor(agent: Agent): boolean { - return foldSubagentDescriptor(agent.session.events.slice(agent.session.header.seedLength ?? 0)) !== undefined + return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.header.seedLength ?? 0)) !== undefined } } diff --git a/packages/experimental/agent-team/tests/invariant.spec.ts b/packages/experimental/agent-team/tests/invariant.spec.ts index ba12a07c74..04d99d75a5 100644 --- a/packages/experimental/agent-team/tests/invariant.spec.ts +++ b/packages/experimental/agent-team/tests/invariant.spec.ts @@ -44,7 +44,7 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - expect(invalid.events).toEqual([]) + expect(invalid.snapshotEvents()).toEqual([]) }) it('rejects an invalid task dependency before publication', async () => { @@ -69,6 +69,6 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) }) diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 44bff729a9..9c85709e2e 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -31,7 +31,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) + for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected return { diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 21d0e61b4a..5596dde448 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -35,7 +35,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) + for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected return { @@ -456,7 +456,7 @@ describe('Team identity and provisioning', () => { await ctx.agentTeams.createTask(lead, { subject: 'parent task', description: 'belongs to parent' }) const handle = await ctx.agents.create({ sessionId: SessionId('ordinary-fork'), - seed: lead.session.events, + seed: lead.session.snapshotEvents(), meta: { parentSession: lead.id, seedLength: lead.session.seq }, agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -979,13 +979,13 @@ describe('Team mailbox and waiting', () => { 'team/message/delivered', ]) - const receiptCount = lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + const receiptCount = lead.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'team-message' && messageIds.has(message.source.messageId))).length await teamFiber.dispose() await ctx.plugin(TeamService, { maxPendingMessagesPerMember: 1 }) await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) }) - expect(lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(lead.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'team-message' && messageIds.has(message.source.messageId)))).toHaveLength(receiptCount) diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 6938caaaa9..cdd1343de2 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json index 3ccd8f242b..90bf0d342d 100644 --- a/packages/experimental/code-runtime-python/package.json +++ b/packages/experimental/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 3268067584..c053446add 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index db96ecc607..da7a2e80ef 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 086f055eaf..d652faa9ef 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 2bd6ccb583..f9f1a454e3 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 2ad0c6c408..77fd181889 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 4673720092..dd615fb994 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 526fabfa2d..d04c9cc9e7 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 12d050a7e5..22e8aca75c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1341,7 +1341,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the live Agent or the stable Session-domain failure.', }, { - signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }>', description: 'Inspect one attached or persisted Session without activating its Agent.', parameters: [{ name: 'sessionId', description: 'durable Session identity.' }, { name: 'signal', description: 'optional caller cancellation for persistence reads.' }], returns: 'the current attached state or persisted header and event prefix.', @@ -4805,7 +4805,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAddress', diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 2795848392..654ac04ff5 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index b2e86a8bfd..e7c28e4501 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 6fa539beb6..647eeb3da4 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -94,7 +94,7 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s /** Authoritative feedback payloads in log order. */ function feedbackTexts(session: Session): string[] { - return session.events + return session.snapshotEvents() .filter(event => event.type === 'feedback/record') .map(event => event.data.text) } @@ -128,15 +128,15 @@ describe('/feedback human command', () => { text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is not configured.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) - const commandRun = test.session.events.find(event => event.type === 'command/run') + const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run') expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) - expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + expect(JSON.stringify(test.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) }) it('exports a command-independent feedback producer', async () => { const test = await harness() commandFeedback.recordFeedback(test.session, ' recorded outside a command ') - expect(test.session.events.map(event => event.type)).toEqual(['feedback/record']) + expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record']) expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') }) .toThrow('feedback text must not be empty') @@ -146,7 +146,7 @@ describe('/feedback human command', () => { it('keeps command bookkeeping around the authoritative feedback event', async () => { const test = await harness() await run(test, ' nothing else happens') - expect(test.session.events.map(event => event.type)).toEqual([ + expect(test.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'feedback/record', 'command/done', ]) }) @@ -209,11 +209,11 @@ describe('/feedback human command', () => { it('keeps every recorded event out of model context and derived history', async () => { const test = await harness() await run(test, ' invisible to the model') - for (const event of test.session.events) { + for (const event of test.session.snapshotEvents()) { expect('surfaceOp' in event).toBe(false) expect(test.session.deriveEventMessage(event)).toBeNull() } - expect(foldSurface(test.session.events).nodes).toEqual([]) + expect(foldSurface(test.session.snapshotEvents()).nodes).toEqual([]) expect(test.session.surface.nodes).toEqual([]) expect(test.session.deriveMessages()).toEqual([]) }) @@ -228,9 +228,9 @@ describe('/feedback human command', () => { await expect(run(test, ' \n\t ')).resolves.toEqual(expected) expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled() expect(feedbackTexts(test.session)).toEqual([]) - const done = test.session.events.filter(event => event.type === 'command/done') + const done = test.session.snapshotEvents().filter(event => event.type === 'command/done') expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) - for (const event of test.session.events) { + for (const event of test.session.snapshotEvents()) { if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) } }) @@ -241,6 +241,6 @@ describe('/feedback human command', () => { controller.abort(new Error('user cancelled the command')) await expect(test.ctx.commands.execute(test.agent, '/feedback too late', [], controller.signal)) .rejects.toThrow('user cancelled the command') - expect(test.session.events).toEqual([]) + expect(test.session.snapshotEvents()).toEqual([]) }) }) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index d133d86830..624ca05af5 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -102,13 +102,13 @@ describe('/feedback real Loader composition through cordis.yml', () => { }) // The domain event owns the payload; generic command bookkeeping omits it. - expect(owner.session.events.map(event => event.type)) + expect(owner.session.snapshotEvents().map(event => event.type)) .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) - const run = owner.session.events.find(event => event.type === 'command/run') + const run = owner.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) - const feedback = owner.session.events.find(event => event.type === 'feedback/record') + const feedback = owner.session.snapshotEvents().find(event => event.type === 'feedback/record') expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable') - expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + expect(JSON.stringify(owner.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) // Nothing reached the model. expect(owner.session.deriveMessages()).toEqual([]) diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 56b3483a80..75820f674c 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index f653ac6d63..748373856d 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -135,7 +135,7 @@ class TestPersistence extends SessionPersistence { const explicit = this.logical.get(id) if (explicit !== undefined) return Promise.resolve(explicit) const live = this.ctx.sessions.get(id) - if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events }) + if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.snapshotEvents() }) const stored = this.durable.get(id) return stored === undefined ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) @@ -171,7 +171,7 @@ class TestPersistence extends SessionPersistence { } persist(session: Session): void { - this.durable.set(session.id, { meta: session.header, events: session.events }) + this.durable.set(session.id, { meta: session.header, events: session.snapshotEvents() }) } setDurable(inspection: SessionInspection): void { diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 5c45e6a0bc..82c49a93b7 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -62,7 +62,7 @@ describe('MessageFeedbackService public contract', () => { }) const fixture = messageFixture('corrupt-session') - persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events }) + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }) const corruption = new Error('stored log checksum mismatch') persistence.inspectFailure = corruption await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) @@ -258,7 +258,7 @@ describe('MessageFeedbackService public contract', () => { rawCtx.provide('sessions', { get: () => undefined } as never) rawCtx.provide('sessionPersistence', { listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), - inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }), + inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }), } as never) const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) await expect(raw.list({ sessionId: fixture.session.id })) @@ -437,7 +437,7 @@ describe('MessageFeedbackService item concurrency', () => { const replacement = Session.create( old.session.id, - old.session.events, + old.session.snapshotEvents(), { ...old.session.header, createdAt: 20, cwd: '/new' }, ) persistence.persist(replacement) @@ -518,7 +518,7 @@ describe('MessageFeedbackService durability ordering', () => { const fixture = messageFixture('cold-prefix') persistence.logical.set(fixture.session.id, { meta: fixture.session.header, - events: fixture.session.events, + events: fixture.session.snapshotEvents(), }) persistence.setDurable({ meta: fixture.session.header, events: [] }) diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index bf7af7b4f9..f31199b47d 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 98869145ad..3a3b713904 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 8aa91b3dcd..91c6c94c97 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 8427868024..aefd3d759b 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 4b2bf55bed..9117645bea 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 1c1941d2e3..4eb8da559c 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 387fc2410c..774d04674c 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -43,7 +43,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => expect(content).not.toContain('draft') // The log records real read/write/edit tool calls (not bash). - const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name) + const calls = agent.session.snapshotEvents().filter(e => e.type === 'tool/call').map(e => e.data.name) expect(calls).toContain('write') expect(calls).toContain('read') expect(calls).toContain('edit') diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 5e50e7fc4e..bb14264f6d 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -806,12 +806,18 @@ describe('sandbox escalation API (write/edit)', () => { /** A fake agent whose session records appends (the approval audit trail), mid-turn, carrying the given events for the fold. */ function escalationAgent(events: Array<{ type: string; data?: Record }> = []): object { + const log = [ + { type: 'turn/start', data: { turn: 1 }, seq: 0 }, + ...events.map((event, index) => ({ ...event, seq: index + 1 })), + ] return { id: 'agent-fs-esc', session: { header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, - events: [{ type: 'turn/start', data: { turn: 1 } }, ...events], - append: (type: string, data: Record) => { events.push({ type, data }) }, + get seq() { return log.length }, + eventAt: (seq: number) => log[seq], + snapshotEvents: () => log, + append: (type: string, data: Record) => { log.push({ type, data, seq: log.length }) }, }, } } diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index d1d4c8d5fb..dc24ba2f9a 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 0b9c51fe5c..0a70ab50a9 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index ed126cc02e..1f1cdf0753 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as commandGoal from '@deepseek-ai/dsh-command-goal' @@ -56,18 +56,18 @@ async function harness(): Promise { } /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */ -function domainEvents(session: Session): readonly Session['events'][number][] { +function domainEvents(session: Session): readonly SessionEvent[] { const lifecycle = new Set() - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type !== 'command/run' && event.type !== 'command/done') continue lifecycle.add(event.seq) // The zero-step wrap around a lifecycle event is bookkeeping too. - const before = session.events[event.seq - 1] - const after = session.events[event.seq + 1] + const before = session.snapshotEvents()[event.seq - 1] + const after = session.snapshotEvents()[event.seq + 1] if (before?.type === 'turn/start') lifecycle.add(before.seq) if (after?.type === 'turn/end') lifecycle.add(after.seq) } - return session.events.filter(event => !lifecycle.has(event.seq)) + return session.snapshotEvents().filter(event => !lifecycle.has(event.seq)) } /** Execute `/goal` through the same registry boundary as a UI adapter. */ diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index 429b9d99d6..fed2823aca 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/src/invariant.ts b/packages/goal/goal-round-driver/src/invariant.ts index 6f072140f4..22ca5c39a1 100644 --- a/packages/goal/goal-round-driver/src/invariant.ts +++ b/packages/goal/goal-round-driver/src/invariant.ts @@ -61,7 +61,7 @@ function validateEvent( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { const prior: SessionEvent[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateEvent(prior, event, fail) prior.push(event) } @@ -70,7 +70,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - validateEvent(session.events, event, fail) + validateEvent(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index f92db57dbf..d2749378bf 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -199,7 +199,7 @@ describe('same-session goal driving', () => { }) expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] - for (const event of test.agent.session.events) { + for (const event of test.agent.session.snapshotEvents()) { // Round zero is a durable goal state change; positive rounds are the // admitted continuation prompts this test counts. if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) { @@ -209,7 +209,7 @@ describe('same-session goal driving', () => { expect(rounds).toEqual([1, 2]) expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2') expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2') - expect(test.agent.session.events.flatMap(event => + expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -265,7 +265,7 @@ describe('same-session goal driving', () => { message: 'Goal round was rejected before entering its step.', }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { @@ -302,7 +302,7 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) // No admitted continuation round reached the model; goal state changes are // represented by their own durable event. - expect(test.agent.session.events.some(event => event.type === 'user/message' + expect(test.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) @@ -330,7 +330,7 @@ describe('same-session goal driving', () => { expect(requestText(test.adapter.requests[0]!)).toContain('human goes first') expect(requestText(test.adapter.requests[0]!)).not.toContain('') expect(requestText(test.adapter.requests[1]!)).toContain('') - expect(test.agent.session.events.flatMap(event => + expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -367,7 +367,7 @@ describe('same-session goal driving', () => { const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 }) - const admitted = test.agent.session.events.find(event => event.type === 'user/message' + const admitted = test.agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' ? admitted.data.source.revision @@ -753,7 +753,7 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('leaves round-zero goal context to the ordinary pre-step chain', async () => { @@ -927,7 +927,7 @@ describe('same-session goal driving', () => { }) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'one ordinary turn' }], source: { kind: 'user' } })) await handle.agent.whenIdle() - const closed = handle.agent.session.events.findLast(event => event.type === 'turn/end') + const closed = handle.agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') if (closed?.type !== 'turn/end') throw new Error('expected a closed turn') await handle.dispose() const warn = vi.spyOn(test.ctx.logger, 'warn') @@ -1032,7 +1032,7 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('ignores session events without an exact owning agent and retires disposed agent state', async () => { diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index abc90ada9b..819bdd2a15 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index 31ac8e2f67..3d8e6a46f0 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -43,7 +43,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): GoalFoldState => { const state = emptyGoalFoldState() - for (const event of session.events) applyChecked(state, event, fail) + for (const event of session.snapshotEvents()) applyChecked(state, event, fail) states.set(session, state) return state } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 3bf0d4505a..9bc98bee74 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -20,7 +20,7 @@ interface StubAgent { /** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { - return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 + return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 } /** Mirror the public Agent.inject contract for domain tests. */ @@ -109,8 +109,8 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['goal/change']) - const context = session.events[0] + expect(session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) + const context = session.snapshotEvents()[0] expect(context?.type).toBe('goal/change') if (context?.type !== 'goal/change') throw new Error('expected durable goal change') const change = decodeGoalChange(context.data) @@ -118,7 +118,7 @@ describe('GoalService creation and replay', () => { expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) expect(agent.inbox.nextStep).toEqual([]) expect(session.deriveMessages()).toEqual([]) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) vi.useRealTimers() }) @@ -170,7 +170,7 @@ describe('GoalService creation and replay', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) - const resumed = stubAgent('seeded-goal', first.session.events) + const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) ctx.agents.register(resumed.agent) expect(ctx.goals.get(resumed.agent)).toMatchObject({ id: created.id, @@ -210,20 +210,20 @@ describe('GoalService creation and replay', () => { expect(ctx.goals.get(agent)?.activation).toBe('disarmed') goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) - expect(() => foldGoal(session.events)).not.toThrow() + expect(() => foldGoal(session.snapshotEvents())).not.toThrow() }) it('lets a lifecycle owner disarm without writing a durable revision', async () => { const { ctx, agent, session } = await harness() const goal = ctx.goals.create(agent, { objective: 'survive driver reload' }) - const before = session.events.length + const before = session.snapshotEvents().length expect(ctx.goals.disarm(agent)).toMatchObject({ id: goal.id, revision: goal.revision, phase: 'active', activation: 'disarmed', }) - expect(session.events).toHaveLength(before) + expect(session.snapshotEvents()).toHaveLength(before) expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) }) @@ -386,7 +386,7 @@ describe('GoalService mutations', () => { const tombstone = ctx.goals.clear(agent, goal) expect(tombstone).toEqual({ id: goal.id, revision: 2 }) expect(ctx.goals.get(agent)).toBeUndefined() - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0, lastRef: tombstone }) expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' })) const next = ctx.goals.create(agent, { objective: 'fresh' }) expect(next.id).not.toBe(goal.id) @@ -402,12 +402,12 @@ describe('GoalService mutations', () => { expect(goal.updatedAt).toBe(100) vi.setSystemTime(80) ctx.goals.clear(agent, goal) - const clear = session.events + const clear = session.snapshotEvents() .filter(event => event.type === 'goal/change') .map(event => event.type === 'goal/change' ? decodeGoalChange(event.data) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) - expect(() => foldGoal(session.events)).not.toThrow() + expect(() => foldGoal(session.snapshotEvents())).not.toThrow() vi.useRealTimers() }) @@ -428,11 +428,11 @@ describe('GoalService mutations', () => { goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' }) goal = ctx.goals.pause(agent, goal) expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' }) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'goal/change', 'goal/change', 'goal/change', ]) expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) - expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) it('publishes a mutation consistently to a reentrant session observer', async () => { @@ -452,7 +452,7 @@ describe('GoalService mutations', () => { expect(observed).toEqual(created) expect(ctx.goals.get(stub.agent)).toEqual(created) - expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } }) + expect(foldGoal(stub.session.snapshotEvents())).toMatchObject({ goal: { id: created.id, revision: 1 } }) }) it('does not delegate goal persistence to agent injection', async () => { @@ -469,7 +469,7 @@ describe('GoalService mutations', () => { revision: 1, }) expect(stub.agent.inbox.nextStep).toEqual([]) - expect(stub.session.events.map(event => event.type)).toEqual(['goal/change']) + expect(stub.session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) }) it('observes an external goal change and disarms local activation', async () => { @@ -529,7 +529,7 @@ describe('goal replay validation', () => { function oneChange(change: GoalChangeMeta) { const session = Session.create(SessionId(`validation-${Math.random()}`)) appendChange(session, change) - return session.events + return session.snapshotEvents() } function mutation( @@ -560,7 +560,7 @@ describe('goal replay validation', () => { const change = snapshotChange() const session = Session.create(SessionId('inbox-independent-change')) appendChange(session, change) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) const message = createUserMessage({ content: [{ type: 'text', text: 'unrelated pending context' }], source: { kind: 'plugin', plugin: 'test' }, @@ -568,14 +568,14 @@ describe('goal replay validation', () => { const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) inbox.append('next-step', message) expect(inbox.remove(message.id)).toBe(true) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) }) function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType { const session = Session.create(SessionId(`validation-pair-${Math.random()}`)) appendChange(session, first) appendChange(session, second) - return foldGoal(session.events) + return foldGoal(session.snapshotEvents()) } it('ignores unrelated metadata and non-goal round sources', () => { @@ -586,7 +586,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'other' }], source: { kind: 'plugin', plugin: 'test' }, })) - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const const turn = nextTurn(session) session.append('turn/start', { turn }) @@ -594,14 +594,14 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'ordinary' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) }) it('rejects rounds attributed to another goal', () => { const change = snapshotChange() const session = Session.create(SessionId('other-goal-round'), oneChange(change)) appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) - expect(() => foldGoal(session.events)).toThrow('not the next admitted round') + expect(() => foldGoal(session.snapshotEvents())).toThrow('not the next admitted round') }) it('rejects unsupported versions, operations, and extra top-level fields', () => { @@ -677,7 +677,7 @@ describe('goal replay validation', () => { appendRound(session, base.goal, 2) appendChange(session, { ...paused, roundsStarted: 2 }) appendChange(session, exhausted) - expect(() => foldGoal(session.events)).toThrow('exhausted round budget') + expect(() => foldGoal(session.snapshotEvents())).toThrow('exhausted round budget') }) it('rejects invalid clear continuity and goal id reuse', () => { @@ -701,7 +701,7 @@ describe('goal replay validation', () => { appendChange(completedSession, base) appendChange(completedSession, complete) appendChange(completedSession, sameCurrentId) - expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(completedSession.snapshotEvents())).toThrow('fresh active revision-one') const second = snapshotChange({ goal: { ...base.goal, id: GoalId('goal-second') }, @@ -715,7 +715,7 @@ describe('goal replay validation', () => { appendChange(nonAdjacentReuse, second) appendChange(nonAdjacentReuse, secondComplete) appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 }) - expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(nonAdjacentReuse.snapshotEvents())).toThrow('fresh active revision-one') const clear: GoalChangeMeta = { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11, @@ -724,7 +724,7 @@ describe('goal replay validation', () => { appendChange(clearedSession, base) appendChange(clearedSession, clear) appendChange(clearedSession, sameCurrentId) - expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(clearedSession.snapshotEvents())).toThrow('fresh active revision-one') }) it('rejects non-positive goal round sources', () => { @@ -736,7 +736,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(() => foldGoal(session.events)).toThrow('goal message source is invalid') + expect(() => foldGoal(session.snapshotEvents())).toThrow('goal message source is invalid') }) it('rejects malformed snapshots, refs, counters, and timestamps', () => { @@ -783,7 +783,7 @@ describe('goal replay validation', () => { clearedAt: 20, } appendChange(session, clear) - expect(foldGoal(session.events)).toEqual({ + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0, lastRef: { id: change.goal.id, revision: 2 }, }) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index f9ef917584..4dd473db31 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -136,7 +136,7 @@ describe('goal projection unit', () => { })) expect(bench.tailValues().goal).toBeNull() - expect(foldGoal(bench.session.events).goal).toBeUndefined() + expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined() }) it('retains strict replay failures without throwing from the projection drive', () => { diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index b1c78d0e79..5b25ef454a 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index bc4ae8faaf..29a820c8df 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -30,7 +30,7 @@ function openTurnEvents( ctx: Context, agent: Agent, ): Pick { - const events = agent.session.events + const events = agent.session.snapshotEvents() const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') if (boundary === undefined || boundary.openTurnStartSeq === null) { reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 86765f87c8..ab544d1b9c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -49,7 +49,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { /** Open one message-triggered turn with its accepted model-visible input. */ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { - const turn = stub.session.events + const turn = stub.session.snapshotEvents() .filter(event => event.type === 'turn/start') .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 const message = createUserMessage({ @@ -262,7 +262,7 @@ describe('goal tool execution authority', () => { const created = ctx.goals.create(root.agent, { objective: 'resume the fork' }) closeTurn(root, originalTurn) const forkId = SessionId('goal-tool-resumed-fork') - const forkSession = Session.create(forkId, root.session.events, { + const forkSession = Session.create(forkId, root.session.snapshotEvents(), { version: SESSION_FORMAT_VERSION, id: forkId, createdAt: Date.now(), diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index c8ca64621a..4d64d22509 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index 58369c16b7..0ac97a7a0b 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -40,7 +40,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { - return [...agent.session.events] + return agent.session.snapshotEvents() .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), @@ -340,7 +340,7 @@ describe('fold onto the downstream decision', () => { expect(found[1]!.source).toEqual(guardSource('probe', 2)) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. - const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = agent.session.snapshotEvents().filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results.every(r => r.data.message.content[0].isError)).toBe(true) expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'nope' }]) }) @@ -364,7 +364,7 @@ describe('fold onto the downstream decision', () => { const found = reminders(agent) expect(found).toHaveLength(1) expect(found[0]!.text).toContain('repeating the exact same tool call') - const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = agent.session.snapshotEvents().filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'replaced' }]) }) }) diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index e297512328..a583d7d507 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index bc639b05c9..d3b689ca6c 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index cee3331842..660241f8fd 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -74,7 +74,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): HookTrace => { const trace: HookTrace = { openTurn: null, pending: new Map() } traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null const transition = validateHookEvent(trace, event, fail) diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index 18f1383938..45aeca1050 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -12,7 +12,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) - const ev = [...session.events].find(e => e.type === 'hook/invoked') + const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') expect(ev?.type).toBe('hook/invoked') if (ev?.type === 'hook/invoked') { expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) @@ -25,7 +25,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) - const ev = [...session.events].find(e => e.type === 'hook/invoked') + const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { expect('matcher' in ev.data).toBe(false) } @@ -37,7 +37,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) - const full = [...session.events].find(e => e.type === 'hook/result') + const full = session.snapshotEvents().find(e => e.type === 'hook/result') if (full?.type === 'hook/result') { expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 }) } @@ -48,7 +48,7 @@ describe('hook/* session events', () => { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) - const sparse = [...session2.events].find(e => e.type === 'hook/result') + const sparse = session2.snapshotEvents().find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) expect('stderrSummary' in sparse.data).toBe(false) @@ -63,7 +63,7 @@ describe('hook/* session events', () => { // An explicit decision wins over the continue:false fallback. appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) }) - const decisions = [...session.events] + const decisions = session.snapshotEvents() .filter(e => e.type === 'hook/result') .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']]) @@ -75,7 +75,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) - const ev = [...session.events].find(e => e.type === 'hook/result') + const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') } @@ -87,7 +87,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) - const ev = [...session.events].find(e => e.type === 'hook/result') + const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('y'.repeat(500)) } @@ -98,8 +98,8 @@ describe('hook/* session events', () => { appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'pair-1' }) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) - const invoked = [...session.events].find(e => e.type === 'hook/invoked') - const result = [...session.events].find(e => e.type === 'hook/result') + const invoked = session.snapshotEvents().find(e => e.type === 'hook/invoked') + const result = session.snapshotEvents().find(e => e.type === 'hook/result') expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1') expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1') }) diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 28b881203f..68f197d544 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index af323d9b63..fb24106d0e 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -72,8 +72,8 @@ function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 4be08bb348..af39599dd2 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -54,7 +54,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 0eb3e1f88e..9a91ad2aed 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index bac95b7eed..bdf476e0fe 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -56,7 +56,7 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 7fed07518a..04217b1ee3 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -44,7 +44,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 1329d21fd3..c9a35316a2 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 0ad77565f7..96dbc0575f 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 7e6ac2f0db..b388065a63 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 1a9afb1b66..9ee272792d 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 2b22b96b3e..d5668c0942 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 24996d8769..e754fd8d3c 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index cfe6a5fb76..a2cbd21924 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index d692dbd633..83c6afed32 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 6b3a0c124b..c3ebb0305a 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/src/invariant.ts b/packages/interaction/commands/src/invariant.ts index ff5d60a7cb..7bce241224 100644 --- a/packages/interaction/commands/src/invariant.ts +++ b/packages/interaction/commands/src/invariant.ts @@ -35,7 +35,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`) } const source = event.data.sourceEventSeq - const sourceEvent = source === undefined ? undefined : session.events[source] + const sourceEvent = source === undefined ? undefined : session.eventAt(source) if (source !== undefined && (event.data.kind !== 'success' || !Number.isSafeInteger(source) || source < 0 || source >= event.seq @@ -46,7 +46,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } } for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(session, event) + for (const event of session.snapshotEvents()) validateEvent(session, event) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index a95ee024dc..655cdc5462 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -33,7 +33,7 @@ async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scop /** The lifecycle slice of one agent's log (boundary markers stripped). */ function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> { - return agent.session.events + return agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => ({ type: event.type, data: event.data })) } @@ -316,7 +316,7 @@ describe('CommandRuntime', () => { // The execution's pairing id is the logged one (RPC-level correlation). expect(execution?.commandId).toBe(ids[0]) // Direct log-only appends: no turn is opened for the pair on an idle log. - expect(agent.session.events.map(event => event.type)).toEqual([ + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'command/done', ]) }) @@ -354,7 +354,7 @@ describe('CommandRuntime', () => { await ctx.commands.execute(agent, '/private keep this once', [], new AbortController().signal) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) - const run = agent.session.events.find(event => event.type === 'command/run') + const run = agent.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type).toBe('command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) }) @@ -428,7 +428,7 @@ describe('CommandRuntime', () => { const signal = new AbortController().signal await ctx.commands.execute(agent, 'not a command', [], signal) await ctx.commands.execute(agent, '/missing', [], signal) - expect(agent.session.events).toEqual([]) + expect(agent.session.snapshotEvents()).toEqual([]) }) it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => { @@ -437,7 +437,7 @@ describe('CommandRuntime', () => { ctx.commands.register(command('mid')) agent.session.append('turn/start', { turn: 1 }) await ctx.commands.execute(agent, '/mid', [], new AbortController().signal) - expect(agent.session.events.map(event => event.type)).toEqual([ + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', ]) }) diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 6f1b2ddcf3..65f17dd8e7 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/src/invariant.ts b/packages/interaction/permission-presets/src/invariant.ts index b6f66adb1c..dee9db2451 100644 --- a/packages/interaction/permission-presets/src/invariant.ts +++ b/packages/interaction/permission-presets/src/invariant.ts @@ -21,7 +21,7 @@ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure /** Install validation that loaded and newly appended preset events remain resolvable. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(ctx, event, fail) + for (const event of session.snapshotEvents()) validateEvent(ctx, event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 065a7ea155..3da5749e2b 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -151,7 +151,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-set') ctx.permissionPresets.set(session, 'danger-full-access') - expect(session.events.map(e => [e.type, e.data])).toEqual([ + expect(session.snapshotEvents().map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], @@ -162,7 +162,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-noop') ctx.permissionPresets.set(session, 'workspace-write') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => { @@ -173,7 +173,7 @@ describe('PermissionPresetService', () => { // the changed knob. session.append('sandbox/mode', { mode: 'read-only' }) ctx.permissionPresets.set(session, 'danger-full-access') - const tail = session.events.slice(4) + const tail = session.snapshotEvents().slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], @@ -208,7 +208,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') ctx.permissionPresets.set(session, 'workspace-write') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) expect(ctx.permissionPresets.current(session)).toBe('workspace-write') }) }) @@ -217,7 +217,7 @@ describe('new-session default', () => { it('pins the current setting into each new session without changing earlier sessions', async () => { const ctx = await mountedStore() const first = ctx.sessions.create(SessionId('first')) - expect(first.events.map(event => [event.type, event.data])).toEqual([ + expect(first.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'workspace-write' }], ['sandbox/mode', { mode: 'workspace-write' }], ['approval/policy', { policy: 'ask' }], @@ -230,7 +230,7 @@ describe('new-session default', () => { const second = ctx.sessions.create(SessionId('second')) expect(ctx.permissionPresets.current(first)).toBe('workspace-write') expect(ctx.permissionPresets.current(second)).toBe('danger-full-access') - expect(second.events.map(event => event.type)).toEqual([ + expect(second.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -243,9 +243,9 @@ describe('new-session default', () => { const legacy = freshSession('legacy-source') legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) + const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.snapshotEvents() }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ + expect(resumed.snapshotEvents().slice(-3).map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -257,7 +257,7 @@ describe('new-session default', () => { }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - expect(resumed.events.map(event => event.type)).toEqual([ + expect(resumed.snapshotEvents().map(event => event.type)).toEqual([ 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -274,10 +274,10 @@ describe('new-session default', () => { }) ctx.provide('approval', { config: { policy: 'ask' } }) const existing = ctx.sessions.create(SessionId('existing-before-permission')) - expect(existing.events).toEqual([]) + expect(existing.snapshotEvents()).toEqual([]) await ctx.plugin(PermissionPresetService, {}) - expect(existing.events.map(event => event.type)).toEqual([ + expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) expect(ctx.permissionPresets.current(existing)).toBe('workspace-write') @@ -302,7 +302,7 @@ describe('new-session default', () => { // The remount sweep must read the folded knob events instead of treating // the session as fresh; no default preset events may overwrite the // overrides (read-only + never matches no preset table entry). - expect(existing.events.map(event => event.type)).toEqual([ + expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'sandbox/mode', 'approval/policy', ]) expect(ctx.permissionPresets.current(existing)).toBe(CUSTOM_PRESET) @@ -313,8 +313,8 @@ describe('new-session default', () => { const partial = freshSession('partial-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) partial.append('approval/policy', { policy: 'ask' }) - const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events }) - expect(resumed.events.at(-1)).toMatchObject({ + const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.snapshotEvents() }) + expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'permission/preset', data: { preset: 'workspace-write' }, }) @@ -322,17 +322,17 @@ describe('new-session default', () => { const custom = freshSession('custom-source') custom.append('sandbox/mode', { mode: 'read-only' }) custom.append('approval/policy', { policy: 'never' }) - const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) + const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.snapshotEvents() }) expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET) - expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') + expect(unmatched.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => { const ctx = await mountedStore({ approvalDefault: undefined }) const partial = freshSession('approval-fallback-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) - const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events }) - expect(resumed.events.at(-1)).toMatchObject({ + const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.snapshotEvents() }) + expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' }, }) diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index f382d4033d..53022e9089 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -99,7 +99,7 @@ describe('/permission command', () => { text: 'The approval policy changed from "ask" to "never" (changed by the user).', }], }) - const run = session.events.find(event => event.type === 'command/run') + const run = session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) }) @@ -111,13 +111,13 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'permission/preset')).toHaveLength(1) }) it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() const { agent } = await agentFor(ctx, session) - const before = session.events.filter(event => + const before = session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', [], new AbortController().signal) // The error text carries the same no-self-labelling rule as the success @@ -127,7 +127,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => + expect(session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index a8d6a9a69f..458a604fef 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index efc45a2f97..a4bd9e8d28 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 5be03a1ef7..c59295d6e4 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -10,7 +10,7 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -25,7 +25,7 @@ declare module '@deepseek-ai/dsh-session/types' { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -66,22 +66,6 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t /** Model-facing statement for an interactive policy that may still fail closed. */ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' -/** - * The session's approval-policy override: the last `approval/policy` event in - * the log, or undefined when the session never switched (callers apply the - * plugin's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the policy of the last switch event, or undefined without one. - */ -export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'approval/policy') return event.data.policy - } - return undefined -} - /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -89,9 +73,9 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv * commit/replay boundary, so a bare event appended between turns is * indistinguishable from a crash tail and silently dropped on reload. */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type +function hasOpenTurn(session: Session): boolean { + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + const type = session.eventAt(seq)?.type if (type === 'turn/start') return true if (type === 'turn/end') return false } @@ -221,7 +205,7 @@ export class ApprovalService extends Service { */ async request(req: ApprovalRequest): Promise { const session = req.agent.session - if (!hasOpenTurn(session.events)) { + if (!hasOpenTurn(session)) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -257,7 +241,11 @@ export class ApprovalService extends Service { * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.events) + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + const event = session.eventAt(seq) + if (event?.type === 'approval/policy') return event.data.policy + } + return undefined } /** diff --git a/packages/interaction/user-approval/src/invariant.ts b/packages/interaction/user-approval/src/invariant.ts index bf3ca8d18d..8ff7a9953e 100644 --- a/packages/interaction/user-approval/src/invariant.ts +++ b/packages/interaction/user-approval/src/invariant.ts @@ -64,7 +64,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): ApprovalTrace => { const trace: ApprovalTrace = { openTurn: null, pending: new Set() } traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null const transition = validateApprovalEvent(trace, event, fail) diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index eb0484a1db..70e1abcf4e 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -7,22 +7,26 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService, { ApprovalOutcome, ApprovalRequest, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' /** * A minimal Agent stand-in — the service only reaches `agent.session.append` - * and folds `.events`. Seeded inside an open turn by default (request()'s + * and indexed log reads. Seeded inside an open turn by default (request()'s * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { const appended: Array<{ type: string; data: Record }> = [] + const events: Array<{ type: string; data?: Record }> = [...seed] const agent = { session: { - events: seed, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], append: (type: string, data: Record) => { - appended.push({ type, data }) - return { type, data } as unknown as SessionEvent + const event = { type, data } + events.push(event) + appended.push(event) + return event as unknown as SessionEvent }, }, } as unknown as Agent @@ -128,9 +132,9 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once') - const audit = session.events.filter(event => event.type.startsWith('approval/')) - const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data.id).toBe(asked?.data.id) expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append')) @@ -151,9 +155,9 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') - const audit = session.events.filter(event => event.type.startsWith('approval/')) - const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append')) @@ -164,7 +168,8 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - events: [{ type: 'turn/start' }], + seq: 1, + eventAt: () => ({ type: 'turn/start' }), append: () => { throw failure }, }, } as unknown as Agent @@ -365,12 +370,13 @@ describe('approval policy (the approval/policy fold)', () => { } it('folds to the last event, or undefined without one', () => { + const service = new ApprovalService(new Context(), {}) const { session } = sessionAgent('sess-fold') - expect(effectiveApprovalPolicy(session.events)).toBeUndefined() + expect(service.overrideOf(session)).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(effectiveApprovalPolicy(session.events)).toBe('ask') - expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) + expect(service.overrideOf(session)).toBe('ask') + expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) it('rejects a policy outside the closed vocabulary before appending', () => { @@ -409,8 +415,8 @@ describe('approval policy (the approval/policy fold)', () => { await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') expect(consulted).not.toHaveBeenCalled() // The audit pair still lands on the session log. - expect(session.events.filter(e => e.type === 'approval/asked')).toHaveLength(1) - expect(session.events.filter(e => e.type === 'approval/decided')).toHaveLength(1) + expect(session.snapshotEvents().filter(e => e.type === 'approval/asked')).toHaveLength(1) + expect(session.snapshotEvents().filter(e => e.type === 'approval/decided')).toHaveLength(1) }) it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => { @@ -458,7 +464,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(effectiveApprovalPolicy(session.events)).toBe('never') + expect(ctx.approval.overrideOf(session)).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 5d8662b50e..71c2cf5610 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 6a3b59e6a1..3844455949 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index d3d46ef8cb..265f002981 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 7e4af851d5..bbed927a7f 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 48cf15aac4..714930108d 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 81f3ae3e65..e02fc06cd9 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 09b2025b44..3bed504dba 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 85fc3db29d..cfd134f485 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 7b454bcc4e..8655f6d661 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -147,9 +147,10 @@ function validateStarted( /** Validate every retry record already present in one loaded session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const [index, event] of session.events.entries()) { - if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) - else if (event.type === 'llm/retry-started') validateStarted(session.events.slice(0, index), event, fail) + const events = session.snapshotEvents() + for (const [index, event] of events.entries()) { + if (event.type === 'llm/retry') validateRetry(events.slice(0, index), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(events.slice(0, index), event, fail) } } @@ -160,8 +161,8 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - if (event.type === 'llm/retry') validateRetry(session.events, event, fail) - else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail) + if (event.type === 'llm/retry') validateRetry(session.snapshotEvents(), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index b4bef4c239..1ce91a8b29 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -112,7 +112,7 @@ describe('real Loader composition', () => { await agent.whenIdle() expect(adapter.requests).toBe(2) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'recovered' }], diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 30ffd662e9..1516ff20a4 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -215,7 +215,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data)) + expect(agent.session.snapshotEvents().filter(item => item.type === 'step/start').map(item => item.data)) .toEqual([{ turn: 1, step: 1 }]) expect(agent.session.deriveMessages().at(-1)).toEqual({ id: expect.any(String) as unknown, @@ -250,7 +250,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message').map(event => ({ turn: event.data.turn, step: event.data.step, }))).toEqual([{ turn: 1, step: 1 }]) @@ -286,14 +286,14 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(500) await idle - const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') - const failedChunks = agent.session.events.filter(event => + const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + const failedChunks = agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && retryEvent !== undefined && event.seq < retryEvent.seq, ) expect(failedChunks).toHaveLength(7) - const assistantMessages = agent.session.events.filter(event => event.type === 'assistant/message') + const assistantMessages = agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') expect(assistantMessages.map(event => ({ turn: event.data.turn, step: event.data.step, @@ -301,7 +301,7 @@ describe('provider-routed retry policy', () => { expect(failedChunks.every(event => !assistantMessages[0]?.sourceEventSeqs?.includes(event.seq), )).toBe(true) - expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'tool/call')).toBe(false) expect(toolExecutions).toBe(0) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', @@ -338,8 +338,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } }, }) @@ -394,7 +394,7 @@ describe('provider-routed retry policy', () => { rejectedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await rejectedIdle expect(rejected.requests).toHaveLength(1) - expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(rejectedAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it('uses local jittered backoff when always mode receives an over-cap Retry-After', async () => { @@ -432,7 +432,7 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(vi.getTimerCount()).toBe(0) }) @@ -451,8 +451,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - const end = agent.session.events.at(-1) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } }, @@ -480,7 +480,7 @@ describe('provider-routed retry policy', () => { const normalIdle = waitForIdle(context, normalAgent) normalAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } })) await normalIdle - expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(normalAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) const alwaysAgent = context.agentLoop.create(SessionId('retry-provider-always'), { provider: 'other', @@ -566,7 +566,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => ({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => ({ provider: event.data.provider, retry: event.data.retry, }))).toEqual([ @@ -677,7 +677,7 @@ describe('provider-routed retry policy', () => { await vi.runAllTimersAsync() await idle - const events = agent.session.events.filter(event => event.type === 'llm/retry') + const events = agent.session.snapshotEvents().filter(event => event.type === 'llm/retry') expect(adapter.requests).toHaveLength(5) expect(events.map(event => ({ provider: event.data.provider, @@ -721,7 +721,7 @@ describe('provider-routed retry policy', () => { const retriedContext = JSON.stringify(adapter.requests[1]?.messages) expect(retriedContext).not.toContain(diagnostic) expect(retriedContext).not.toContain('discarded partial output') - expect(agent.session.events.some(event => + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry' && event.data.failure.message === diagnostic, )).toBe(true) }) @@ -743,7 +743,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it.each([ @@ -794,7 +794,7 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(60_000) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) @@ -835,7 +835,7 @@ describe('provider-routed retry policy', () => { expect(order[0]).toBe('downstream') expect(order).toEqual(expect.arrayContaining(['disposed', 'idle'])) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it('drains delegated recovery before turn cancellation reaches idle', async () => { @@ -873,7 +873,7 @@ describe('provider-routed retry policy', () => { expect(order).toEqual(['downstream', 'idle']) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -910,7 +910,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -967,7 +967,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -996,8 +996,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -1020,7 +1020,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 7591f8fdf2..c9b8c7eaca 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -103,10 +103,10 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server).toBeDefined() expect(server?.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start') + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('connected after retry') }) @@ -132,16 +132,16 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(2) expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) - const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') - expect(agent.session.events.filter(event => + const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && retryEvent !== undefined && event.seq < retryEvent.seq, )).toHaveLength(failedChunkCount) - expect(agent.session.events.filter(event => event.type === 'assistant/message') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('recovered response') }) @@ -161,12 +161,12 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(2) expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['EMPTY_RESPONSE']) - expect(agent.session.events.filter(event => event.type === 'assistant/message') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -188,12 +188,12 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(1) - expect(agent.session.events.filter(event => + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && event.data.turn === 1, )).toHaveLength(3) - expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, }) @@ -215,7 +215,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success']) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TIMEOUT']) expect(finalAssistantText(agent)).toBe('recovered after timeout') }, 10_000) @@ -233,9 +233,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) - const end = agent.session.events.at(-1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 7aca93a359..ce1a4c466a 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 4833766590..23d24babe6 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 428334feea..f0a55b2021 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 85742823d9..d97d9d3fc2 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -207,9 +207,9 @@ export class TokenMeter extends Service { this.states.set(session, state) } - while (state.consumedEvents < session.events.length) { + while (state.consumedEvents < session.seq) { // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log - const event = session.events[state.consumedEvents]! + const event = session.eventAt(state.consumedEvents)! this._foldEvent(session, state, event) state.consumedEvents += 1 } @@ -315,7 +315,7 @@ export class TokenMeter extends Service { seen.add(seq) // Session construction validates contiguous seqs, and the explicit // earlier-than-assistant check above therefore guarantees existence. - const source = session.events[seq] + const source = session.eventAt(seq) // oxlint-disable-next-line typescript/no-non-null-assertion const sourceEvent = source! if (sourceEvent.type !== 'assistant/chunk') { diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 84f1dd1788..198a660992 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -219,7 +219,7 @@ describe('TokenMeter pricing', () => { const result = service.measure(session) expect(result.baseline.kind).toBe('estimated') expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens) - expect(result.logRevision).toBe(session.events.length) + expect(result.logRevision).toBe(session.snapshotEvents().length) expectSurfaceTotal(result) }) @@ -404,7 +404,7 @@ describe('replay anchors and surface folds', () => { content: [{ type: 'text', text: 'new tail' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const seeded = Session.create(SessionId('surface-seeded'), original.events) + const seeded = Session.create(SessionId('surface-seeded'), original.snapshotEvents()) const before = service.measure(seeded) expect(before.nodes).toHaveLength(2) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) @@ -417,15 +417,15 @@ describe('replay anchors and surface folds', () => { }), { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] }) const after = service.measure(seeded) expect(after.nodes).toHaveLength(2) - expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1) - expect(after.logRevision).toBe(seeded.events.length) + expect(after.nodes[0]!.seq).toBe(seeded.snapshotEvents().length - 1) + expect(after.logRevision).toBe(seeded.snapshotEvents().length) expect(Object.isFrozen(after.nodes)).toBe(true) expect(Object.isFrozen(after.nodes[0])).toBe(true) expect(after.surfaceDeltaTokens).toBeLessThan(0) expectSurfaceTotal(after) expect(before.nodes).toHaveLength(2) // The earlier snapshot still reports the log it measured: seed + boundary. - expect(before.logRevision).toBe(original.events.length + 1) + expect(before.logRevision).toBe(original.snapshotEvents().length + 1) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) }) @@ -437,7 +437,7 @@ describe('replay anchors and surface folds', () => { provenance: 'empty', }) const measurement = meter().measure(session) - const assistant = session.events.find(event => event.type === 'assistant/message')! + const assistant = session.snapshotEvents().find(event => event.type === 'assistant/message')! expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0, heuristicTokens: 0 }]) expect(measurement.surfaceTokens).toBe(0) expectSurfaceTotal(measurement) @@ -650,7 +650,7 @@ describe('malformed replay and listener lifecycle', () => { source: { kind: 'user' }, }), { surfaceOp: 'append' }) appendHeader(session, header('deepseek-v4-flash')) - const head = session.events[0]!.seq + const head = session.snapshotEvents()[0]!.seq session.append('assistant/message', { turn: 1, step: 1, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 8c9c34e5cd..baa149122d 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index de3eb763c5..6ff7ff66ba 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index f35f7a8cda..db6aae0e9d 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 1ade60d909..846c359c77 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 352bc7e56c..c78fcb10e2 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index 634efadfb5..72a319fc45 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -28,7 +28,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended plan-mode state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const seed = (session: Session): void => { - for (const event of session.events) validateEvent(event, fail) + for (const event of session.snapshotEvents()) validateEvent(event, fail) } for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index a40df90638..0cbb6649d7 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -86,7 +86,7 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const header = findEvent(log, 'request/header') expect(planMode.seq).toBeLessThan(header.seq) @@ -114,14 +114,14 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(planActive(ctx, agent)).toBe(false) - const first = findEvent(agent.session.events, 'request/header') + const first = findEvent(agent.session.snapshotEvents(), 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) ctx.planMode.set(agent, true) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() expect(planActive(ctx, agent)).toBe(true) const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notices).toHaveLength(1) @@ -163,7 +163,7 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section) expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools) expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const nextIdle = waitForIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } })) @@ -172,7 +172,7 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests).toHaveLength(3) expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section) expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools) - const log = agent.session.events + const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const firstEnd = log.find(event => event.type === 'step/end' && event.data.turn === 1 && event.data.step === 1) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index f29e15d3fc..cd53724e34 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -142,7 +142,7 @@ function header(session: Session): void { } function noticeTexts(session: Session): string[] { - return session.events + return session.snapshotEvents() .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join('')) } @@ -205,19 +205,19 @@ describe('resolveConfig', () => { describe('foldPlanMode', () => { it('folds an empty log to inactive and takes the last plan/mode otherwise', () => { const session = Session.create(SessionId('fold')) - expect(foldPlanMode(session.events)).toBe(false) + expect(foldPlanMode(session.snapshotEvents())).toBe(false) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) session.append('plan/mode', { active: true }) - expect(foldPlanMode(session.events)).toBe(true) + expect(foldPlanMode(session.snapshotEvents())).toBe(true) }) it('folds a prefix when `end` is given', () => { const session = Session.create(SessionId('fold-prefix')) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) - expect(foldPlanMode(session.events, 1)).toBe(true) - expect(foldPlanMode(session.events, 0)).toBe(false) + expect(foldPlanMode(session.snapshotEvents(), 1)).toBe(true) + expect(foldPlanMode(session.snapshotEvents(), 0)).toBe(false) }) }) @@ -281,14 +281,14 @@ describe('ctx.planMode: get/set', () => { const ctx = await setup() const agent = await agentWithSession(ctx, 'agent-idle') expect(ctx.planMode.set(agent, true)).toBe('committed') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) // Immediately reversible, still without a boundary. expect(ctx.planMode.set(agent, false)).toBe('committed') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) // A later boundary finds nothing pending — no double append. await boundary(ctx, agent, 'step-start') - expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'plan/mode')).toHaveLength(2) }) it('a between-turns reversal of a mid-turn pending intent cancels without logging', async () => { @@ -299,7 +299,7 @@ describe('ctx.planMode: get/set', () => { closeTurn(agent.session) // Back to the logged state: the pending intent clears, nothing lands. expect(ctx.planMode.set(agent, false)).toBe('cancelled') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(ctx.planMode.get(agent)).toEqual({ active: false }) }) @@ -319,7 +319,7 @@ describe('the boundary flush', () => { const service = ctx.planMode as unknown as { onBoundary(session: Session): void } expect(() => { service.onBoundary(agent.session) }).not.toThrow() - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) it('flushes from pre-step before the following step/start', async () => { @@ -328,7 +328,7 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'pre-step') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) @@ -343,7 +343,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) await fiber.dispose() await boundary(ctx, agent, 'pre-step') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) it('flushes at the between-step seam too', async () => { @@ -351,7 +351,7 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -362,7 +362,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) ctx.planMode.set(agent, false) await boundary(ctx, agent, 'pre-step') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -403,7 +403,7 @@ describe('the boundary flush', () => { agent.session.append('plan/mode', { active: false }) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -430,7 +430,7 @@ describe('the boundary flush', () => { expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) agent.session.append = original await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent).pending).toBeUndefined() }) @@ -674,7 +674,7 @@ describe('/plan', () => { expect(enteringSteer).not.toHaveBeenCalled() await boundary(ctx, entering, 'step-start') expect(ctx.planMode.get(entering)).toEqual({ active: false }) - expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(entering.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const active = await agentWithSession(ctx, 'active-plan-command', { active: true }) openTurn(active.session) @@ -698,10 +698,10 @@ describe('/plan', () => { const agent = await agentWithSession(ctx, 'idle-plan-command') expect((await ctx.commands.execute(agent, '/plan', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' }) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect((await ctx.commands.execute(agent, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode off.' }) - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) it('steers image attachments with or without text and refuses them on /plan off', async () => { @@ -848,7 +848,7 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }]) } expect(asked).toHaveLength(0) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('degrades to the manual exit when no user-questions seam is composed', async () => { @@ -857,7 +857,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions channel is available to review the plan; ask the user to switch the session mode instead' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => { @@ -865,7 +865,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions answerer accepted the request' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => { @@ -885,7 +885,7 @@ describe('exit_plan_mode', () => { text: "Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result", }]) expect(ask).not.toHaveBeenCalled() - expect(foldPlanMode(child.session.events)).toBe(true) + expect(foldPlanMode(child.session.snapshotEvents())).toBe(true) }) it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => { @@ -897,10 +897,10 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]) // Boundary-applied, not a direct append: the fold stays plan until the // step's end, so the plan policy covers any remaining call of the SAME batch. - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things') @@ -950,7 +950,7 @@ describe('exit_plan_mode', () => { question: 'Approve this plan and leave plan mode?', detail: plan, }) - expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({ + expect(agent.session.snapshotEvents().find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({ name: EXIT_PLAN_MODE, arguments: { plan }, isError: false, @@ -965,12 +965,12 @@ describe('exit_plan_mode', () => { // Calls of the SAME assistant response were requested under the existing // plan-shaped header. Pending state shapes only the proposed next // assembly; the accepted boundary then commits the matching durable fold. - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) const assembly = await ctx.systemPrompt.assemble({ agent }) expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true) expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('') await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) const afterExit = await ctx.systemPrompt.assemble({ agent }) expect(afterExit.tools).toEqual(assembly.tools) expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('') @@ -981,7 +981,7 @@ describe('exit_plan_mode', () => { header(agent.session) await callExit(ctx, agent) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -990,7 +990,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('keep planning without feedback returns the generic corrective error', async () => { @@ -1005,7 +1005,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('requires exactly the single Approve selection', async () => { @@ -1013,7 +1013,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('treats custom text alongside Approve as feedback, not consent', async () => { @@ -1021,7 +1021,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('treats duplicate review answer items as non-consent', async () => { @@ -1035,7 +1035,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('a missing answer item reads as keep-planning', async () => { @@ -1067,7 +1067,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('leaves every other review failure its own message', async () => { @@ -1079,7 +1079,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('forwards the execution abort signal to the review question', async () => { @@ -1119,7 +1119,7 @@ describe('exit_plan_mode', () => { const result = await pending expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => { @@ -1128,7 +1128,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('presents the call as a generic card titled by the plan first heading', async () => { @@ -1179,6 +1179,6 @@ describe('HMR disposal', () => { expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy') await boundary(ctx, agent, 'step-start') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) }) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index ff41e160a7..316f4b9408 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -155,7 +155,7 @@ describe('plan projection unit', () => { // A second registry over the same log (the cold-read shape): no service // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) - for (const event of bench.session.events) { + for (const event of bench.session.snapshotEvents()) { if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a6eb4c8f9c..92fa0d62ba 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index 9a9992e3d3..15b683a986 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -92,7 +92,7 @@ async function agentOn(ctx: Context, id: string, presetId?: string): Promise - agent.session.events.findLast(event => event.type === 'agent-preset/selected')?.data + agent.session.snapshotEvents().findLast(event => event.type === 'agent-preset/selected')?.data describe('the roster a client reads', () => { it('projects path-free rows, marking the default and carrying published metadata', async () => { diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 61e0e600e6..fafce3d11a 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index fcd7e5e9ab..5af83e46bd 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index d40c86bea7..e841d164c9 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index b3582e5060..62e1f278a7 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 32e2c998b7..6f3d5ca654 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -23,7 +23,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended sandbox modes. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(event, fail) + for (const event of session.snapshotEvents()) validateEvent(event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 52172cb09b..a680373163 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -206,7 +206,7 @@ describe('sandbox:policy request context', () => { it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => { const active = session('sess-resume', '/projects/current') setSandboxMode(active, 'workspace-write') - const resumed = Session.create(active.id, active.events, active.header) + const resumed = Session.create(active.id, active.snapshotEvents(), active.header) const ctx = await promptMounted({ mode: 'read-only' }) expect(await policyContext(ctx, resumed)).toContain('workspace-write') @@ -231,7 +231,7 @@ describe('the sandbox/mode session kit', () => { it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { const session = Session.create(SessionId('sess-write')) setSandboxMode(session, 'danger-full-access') - const modeEvents = session.events.filter(e => e.type === 'sandbox/mode') + const modeEvents = session.snapshotEvents().filter(e => e.type === 'sandbox/mode') expect(modeEvents).toHaveLength(1) expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' }) }) diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 212c5a7fa0..3a72769215 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index c129ed0d41..00afdb93e0 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index a14c7f3d40..406b4dceec 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/schedule/schedule/README.md -README.md: 871c47309e5ceb544f4e9fc97c69bbdb5b00c0aa -README.zh.md: 68c66ab22131ea528bd763116b7b343405907912 +README.md: 69ee726bb25aaa8c2da0c53471089d2a255998c9 +README.zh.md: 60d57055ca4ab5e9fa18ae1996d31ccd3e704495 diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 871c47309e..69ee726bb2 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -98,7 +98,7 @@ The package rests on one separation and three commitments: ### Durable state and replay -A normal Session folds its complete event stream. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so a child never inherits its parent's reminders. The Schedule projection derives that boundary from the immutable `SessionHeader` passed to `init(header)` and applies the same transition function to the same owned suffix. Every create record carries a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record also stores `afterSeconds`, an `at` record stores no copy of its submitted offset or local fields, and an `every` record stores `everySeconds` with `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id; an `every` dispatch adds `acceptedAt`, and replay advances directly to the first anchor-aligned target after that decision time. +A normal Session folds its complete event stream. A fork folds only `session.snapshotEvents(session.header.seedLength ?? 0)`, so a child never inherits its parent's reminders. The Schedule projection derives that boundary from the immutable `SessionHeader` passed to `init(header)` and applies the same transition function to the same owned suffix. Every create record carries a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record also stores `afterSeconds`, an `at` record stores no copy of its submitted offset or local fields, and an `every` record stores `everySeconds` with `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id; an `every` dispatch adds `acceptedAt`, and replay advances directly to the first anchor-aligned target after that decision time. ### Client projection diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 68c66ab221..60d57055ca 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -98,7 +98,7 @@ Session projection 是可选能力。`ctx.sessionProjections` 存在时,插件 ### 持久状态与回放 -普通会话折叠完整事件流。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此子会话永远不会继承父会话的提醒。Schedule projection 从传给 `init(header)` 的不可变 `SessionHeader` 派生该边界,并对同一自有后缀应用同一个 transition 函数。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 +普通会话折叠完整事件流。fork 只折叠 `session.snapshotEvents(session.header.seedLength ?? 0)`,因此子会话永远不会继承父会话的提醒。Schedule projection 从传给 `init(header)` 的不可变 `SessionHeader` 派生该边界,并对同一自有后缀应用同一个 transition 函数。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 ### 客户端 projection diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index b945f131e9..bbeca31693 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 6dfbaeb4f6..9364fc53e0 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -55,7 +55,7 @@ export function apply(ctx: Context): void { const cleanup: OwnerCleanup = agent.ctx.effect(() => { const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { runtime.requestDrive() }) const stopStatus = agent.ctx.on('agent/status', ({ status }) => { - if (status === 'idle' && agent.session.events.some(event => event.type === 'schedule/change')) { + if (status === 'idle' && agent.session.snapshotEvents().some(event => event.type === 'schedule/change')) { runtime.requestDrive() } }) diff --git a/packages/schedule/schedule/src/invariant.ts b/packages/schedule/schedule/src/invariant.ts index 59a9ab6c21..5af5245ade 100644 --- a/packages/schedule/schedule/src/invariant.ts +++ b/packages/schedule/schedule/src/invariant.ts @@ -30,16 +30,16 @@ function validate(events: readonly SessionEvent[], seedLength: number, fail: Inv /** Install replay and pre-append validation for the owned event stream. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - validate(session.events, session.header.seedLength ?? 0, fail) + validate(session.snapshotEvents(), session.header.seedLength ?? 0, fail) } ctx.on('session/created', (session) => { - validate(session.events, session.header.seedLength ?? 0, fail) + validate(session.snapshotEvents(), session.header.seedLength ?? 0, fail) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type !== 'schedule/change') return - validate([...session.events, event], session.header.seedLength ?? 0, fail) + validate([...session.snapshotEvents(), event], session.header.seedLength ?? 0, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index e6f4fce72c..b7f45848c5 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -206,7 +206,7 @@ export class ScheduleRuntime { private readFolded(): FoldedSchedules | undefined { try { return foldScheduleEvents( - this.agent.session.events, + this.agent.session.snapshotEvents(), this.agent.session.header.seedLength ?? 0, ) } catch (error: unknown) { diff --git a/packages/schedule/schedule/src/tools.ts b/packages/schedule/schedule/src/tools.ts index a7f4d3e8a7..616d8650fb 100644 --- a/packages/schedule/schedule/src/tools.ts +++ b/packages/schedule/schedule/src/tools.ts @@ -221,7 +221,7 @@ function inputError(error: ScheduleInputError): ScheduleToolError { /** Fold only after a successful preflight, mapping corruption to a stable value. */ function foldForTool(agent: Agent): ReturnType | ScheduleToolError { try { - return foldScheduleEvents(agent.session.events, agent.session.header.seedLength ?? 0) + return foldScheduleEvents(agent.session.snapshotEvents(), agent.session.header.seedLength ?? 0) } catch (error: unknown) { return error instanceof ScheduleLogError ? corruptLogError() : internalError() } diff --git a/packages/schedule/schedule/tests/invariant.spec.ts b/packages/schedule/schedule/tests/invariant.spec.ts index 88e7465260..0896a8b6f9 100644 --- a/packages/schedule/schedule/tests/invariant.spec.ts +++ b/packages/schedule/schedule/tests/invariant.spec.ts @@ -53,17 +53,17 @@ describe('Schedule package invariant', () => { const session = ctx.sessions.create(SessionId('schedule-invariant')) session.append('turn/start', { turn: 1 }) session.append('schedule/change', create('schedule-1')) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) expect(() => session.append('schedule/change', { version: 1, operation: 'delete', id: ScheduleId('missing'), })).toThrow(InvariantError) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) session.append('schedule/change', { version: 1, operation: 'dispatch', id: ScheduleId('schedule-1') }) - expect(session.events).toHaveLength(3) + expect(session.snapshotEvents()).toHaveLength(3) await ctx.fiber.dispose() }) @@ -82,7 +82,7 @@ describe('Schedule package invariant', () => { id: ScheduleId('schedule-every'), acceptedAt: '2026-08-05T12:17:34.000Z', }) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) await ctx.fiber.dispose() }) @@ -117,7 +117,7 @@ describe('Schedule package invariant', () => { }) const fiber = await ctx.plugin(scheduleInvariant) child.append('schedule/change', create('child')) - expect(child.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(child.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await fiber.dispose() await ctx.fiber.dispose() }) diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index d18e35aac9..879b16d0ad 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -129,7 +129,7 @@ describe('Schedule production JSONL restart', () => { await replayed.sessions.flush(replayHandle.agent.session) expect(replayAdapter.requests).toEqual([]) - expect(replayHandle.agent.session.events.filter(event => + expect(replayHandle.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) const replayedStored = await replayed.sessionPersistence.inspect(sessionId) expect(replayedStored.events.filter(event => diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index 609c68a6cd..86b8246c32 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -173,7 +173,7 @@ describe('Schedule timer and admission runtime', () => { await settle() expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.find(event => + expect(test.agent.session.snapshotEvents().find(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toBeDefined() await runtime.dispose() }) @@ -223,7 +223,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.followed).toEqual([]) expect(test.controls.whenIdleCount).toBe(1) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) runtime.requestDrive() await settle() @@ -295,13 +295,13 @@ describe('Schedule timer and admission runtime', () => { ].join('\n'), }]) expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'schedule' }) - const dispatches = test.agent.session.events.filter(event => + const dispatches = test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch') expect(dispatches.map(event => event.data)).toEqual([ { version: 1, operation: 'dispatch', id: 'schedule-fast', acceptedAt: '2026-08-05T12:00:00.000Z' }, { version: 1, operation: 'dispatch', id: 'schedule-slow', acceptedAt: '2026-08-05T12:00:00.000Z' }, ]) - expect(foldScheduleEvents(test.agent.session.events).active).toEqual([ + expect(foldScheduleEvents(test.agent.session.snapshotEvents()).active).toEqual([ expect.objectContaining({ id: 'schedule-fast', scheduledAt: '2026-08-05T12:05:00.000Z' }), expect.objectContaining({ id: 'schedule-slow', scheduledAt: '2026-08-05T12:09:00.000Z' }), ]) @@ -370,7 +370,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.controls.releaseCount).toBe(1) expect(test.followed).toEqual([]) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'delete' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'delete' }) runtime.requestDrive() await settle() expect(test.followed).toEqual([]) @@ -406,9 +406,9 @@ describe('Schedule timer and admission runtime', () => { appendAfter(unreadable, 'schedule-1', 1, Date.now() - 1_000) unreadable.controls.onReserve = () => { unreadable.controls.onReserve = undefined - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw new Error('became unreadable') }, + value: () => { throw new Error('became unreadable') }, }) } const unreadableRuntime = runtimeFor(unreadable) @@ -429,7 +429,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) await runtime.dispose() @@ -460,7 +460,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) runtime.requestDrive() await settle() @@ -487,7 +487,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => ( + expect(test.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' )).map(event => event.data)).toEqual([{ version: 1, @@ -495,7 +495,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { id: 'schedule-first', acceptedAt: '2026-08-05T12:00:00.000Z', }]) - expect(foldScheduleEvents(test.agent.session.events).active).toEqual([ + expect(foldScheduleEvents(test.agent.session.snapshotEvents()).active).toEqual([ expect.objectContaining({ id: 'schedule-first', scheduledAt: '2026-08-05T12:05:00.000Z' }), expect.objectContaining({ id: 'schedule-second', scheduledAt: '2026-08-05T11:55:00.000Z' }), ]) @@ -545,7 +545,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.flushCount).toBe(1) expect(test.followed).toEqual([]) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await runtime.dispose() const departed = await harness() @@ -607,15 +607,15 @@ describe('Schedule runtime failure and teardown boundaries', () => { } await settle() expect(test.followed).toEqual([]) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) }) it('faults on corrupt or unreadable durable state after preflight', async () => { const corrupt = await harness() - Object.defineProperty(corrupt.agent.session, 'events', { + Object.defineProperty(corrupt.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), data: { version: 9, operation: 'delete', id: 'schedule-1' }, }], @@ -626,9 +626,9 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(corrupt.followed).toEqual([]) const unreadable = await harness() - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw 'unreadable log' }, + value: () => { throw 'unreadable log' }, }) const unreadableRuntime = runtimeFor(unreadable) unreadableRuntime.start() diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index b8ef0be6f8..4ac5d91717 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -159,7 +159,7 @@ describe('Schedule tool protocol', () => { expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 299 }))) .toEqual({ code: 'frequency_too_high', message: 'every_seconds must be at least 300.' }) expect(test.flushes.count).toBe(0) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('creates, lists, marks overdue, deletes, and never reuses an id', async () => { @@ -232,7 +232,7 @@ describe('Schedule tool protocol', () => { expect.objectContaining({ id: 'schedule-1', kind: 'at' }), expect.objectContaining({ id: 'schedule-2', kind: 'at' }), ]) - const changes = test.agent.session.events + const changes = test.agent.session.snapshotEvents() .filter(event => event.type === 'schedule/change' && event.data.operation === 'create') expect(changes.map((change) => { if (change.type !== 'schedule/change' || change.data.operation !== 'create') { @@ -300,7 +300,7 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be strictly in the future.', }) expect(test.flushes.count).toBe(3) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('returns a range error only after the create preflight', async () => { @@ -312,7 +312,7 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', }) expect(test.flushes.count).toBe(1) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const internal = await harness() const now = vi.spyOn(Date, 'now').mockImplementationOnce(() => { throw new Error('clock unavailable') }) @@ -350,9 +350,9 @@ describe('Schedule tool protocol', () => { describe('Schedule persistence failure boundaries', () => { it('does not fold an unconfirmed corrupt live suffix before preflight succeeds', async () => { const test = await harness() - Object.defineProperty(test.agent.session, 'events', { + Object.defineProperty(test.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), @@ -438,7 +438,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(0) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('does not persist a create cancelled during its first preflight', async () => { @@ -462,7 +462,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(1) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('does not persist a delete cancelled during its first preflight', async () => { @@ -485,7 +485,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(3) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')) .toHaveLength(1) expect(value(await execute(test, 'schedule_list', {}))) .toEqual([expect.objectContaining({ id: 'schedule-1' })]) @@ -496,21 +496,21 @@ describe('Schedule persistence failure boundaries', () => { createTest.flushes.outcomes.push('reject') expect(value(await execute(createTest, 'schedule_create', { prompt: 'later', after_seconds: 1 }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'create' }) - expect(createTest.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(createTest.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const deleteTest = await harness() await execute(deleteTest, 'schedule_create', { prompt: 'keep', after_seconds: 1 }) deleteTest.flushes.outcomes.push('reject') expect(value(await execute(deleteTest, 'schedule_delete', { id: 'schedule-1' }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1' }) - expect(deleteTest.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(deleteTest.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) }) it('maps corrupt and unreadable folds for create, list, and delete', async () => { const corrupt = await harness() - Object.defineProperty(corrupt.agent.session, 'events', { + Object.defineProperty(corrupt.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), data: { version: 9, operation: 'delete', id: 'schedule-1' }, }], @@ -521,9 +521,9 @@ describe('Schedule persistence failure boundaries', () => { .toMatchObject({ code: 'corrupt_schedule_log' }) const unreadable = await harness() - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw 'unreadable log' }, + value: () => { throw 'unreadable log' }, }) expect(value(await execute(unreadable, 'schedule_list', {}))) .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' }) diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index f2e6e802a8..ca88b56bc5 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index cbf3373bfa..ac4ea8d193 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index c449cb16d3..f78e17b362 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 60cba7ebdd..93c50c2964 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts index a1e58b11ae..1d61e0ef3a 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts @@ -65,7 +65,7 @@ describe('session-log-download real Loader composition', () => { }) const execution = await context.commands.execute(agent, '/export', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' }) - expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(session.snapshotEvents().map(event => event.type)).toEqual(['command/run', 'command/done']) expect(session.deriveMessages()).toEqual([]) }) }) diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 9e65292c97..9881396363 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 1c4569df4c..f7e4ce043d 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -854,7 +854,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { - return observeSession(session.header, session.events) + return observeSession(session.header, session.snapshotEvents()) } function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 6c362a3d28..7e5e4398e7 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 69fbc8a1a9..487b6a9702 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -239,7 +239,7 @@ function projectSource( } function sourceLive(session: Session): LogicalSessionSource { - return { header: session.header, events: session.events } + return { header: session.header, events: session.snapshotEvents() } } function orderedResults( @@ -292,7 +292,7 @@ async function inspectPersisted( function snapshotLive(session: Session): LogicalSession { return { header: structuredClone(session.header), - events: session.events.map(event => structuredClone(event)), + events: session.snapshotEvents().map(event => structuredClone(event)), } } diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index 8810e1972f..4d56007d63 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -151,7 +151,7 @@ export class SessionObservationReader { session: Session, projectionMode: NonNullable, ): SessionObservation { - const events = Object.freeze([...session.events]) + const events = session.snapshotEvents() const projections = projectionMode === 'none' ? undefined : this.ctx.get('sessionProjections')?.snapshot(session) diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index 7dc856ee29..7151714b67 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -18,7 +18,7 @@ function preparedSource( const preparedSession = Session.create(meta.id, [], meta) return { source: 'prepared', - inspection: { meta: preparedSession.header, events: preparedSession.events }, + inspection: { meta: preparedSession.header, events: preparedSession.snapshotEvents() }, revision: SessionPersistenceRevision(`fixture:${meta.id}`), preparedSession, [Symbol.dispose]: dispose, diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 369fa3b3ae..77161b935f 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1002,7 +1002,8 @@ describe('session-query exact reads', () => { }).toThrow() Object.assign(snapshot.session, { cwd: '/mutated' }) - expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1) + const logged = session.eventAt(4) + expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) expect(session.header.cwd).toBe('/work') }) @@ -1038,7 +1039,8 @@ describe('session-query exact reads', () => { (result.events[0]!.data as { content: unknown[] }).content = [] }).toThrow() expect(session.header.createdAt).not.toBe(-1) - expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1) + const logged = session.eventAt(1) + expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index bf83ca181a..01cec7f91d 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index d1756140f0..9eabee4e46 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -8,7 +8,6 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import { HarnessError } from '@deepseek-ai/dsh-llm' import { - type SessionEvent, type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' @@ -24,7 +23,6 @@ import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader - readonly events: readonly SessionEvent[] /** The caller's own-session boundary fold (the `turnBoundary` projection). */ readonly boundary: TurnBoundaryProjection | undefined } @@ -66,7 +64,6 @@ function callerOf(exec: ToolRunContext, ctx: Context): Caller { return { id: agent.session.id, header: agent.session.header, - events: agent.session.events, boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), } } diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 9db2a45da7..eaa4ce7cfe 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -8,6 +8,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type Session, + type SessionEvent, type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' @@ -1213,7 +1214,7 @@ describe('workspace authority and lineage redaction', () => { const appendLegacy = mounted.caller.append.bind(mounted.caller) as unknown as ( type: string, data: unknown, - ) => Session['events'][number] + ) => SessionEvent const secret = appendLegacy( 'context/message', { @@ -2008,7 +2009,7 @@ describe('trace and exact read rendering', () => { expect(text(result)).toContain('Replacement chain: 1') expect(text(result)).toContain('Events cited directly as sources: none') expect(text(result)).toContain('Direct derived events: 1') - expect(text(result)).toContain(new Date(session.events[0]?.time ?? 0).toISOString()) + expect(text(result)).toContain(new Date(session.snapshotEvents()[0]?.time ?? 0).toISOString()) }) it('renders unabridged fenced target JSON and readable semantic or log-only neighbor summaries', async () => { @@ -2040,7 +2041,7 @@ describe('trace and exact read rendering', () => { const appendLegacy = session.append.bind(session) as unknown as ( type: string, data: unknown, - ) => Session['events'][number] + ) => SessionEvent appendLegacy( 'context/message', { content: [{ type: 'text', text: 'after semantic text' }], source: { kind: 'plugin', plugin: 'test' } }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 5d0bdd6538..8ce5685ed8 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 5f413bbb77..b557ba69f1 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index 19936466d2..b56e0225a1 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -9,7 +9,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { DeepSeekSessionLogExtension } from './types.ts' export type * from './types.ts' @@ -45,10 +45,13 @@ const acceptanceFolds = new WeakMap() export function acceptedThrough(session: Session): number { const previous = acceptanceFolds.get(session) let throughSeq = previous?.throughSeq ?? -1 - const events = session.events + const length = session.seq const start = previous?.scannedEvents ?? 0 - for (let index = start; index < events.length; index++) { - const event = events[index] as SessionEvent + for (let index = start; index < length; index++) { + const event = session.eventAt(index) + if (event === undefined) { + throw new Error(`session-log-deepseek: missing event ${String(index)} below captured length ${String(length)}`) + } if (event.type !== 'session-log-deepseek/delivery-accepted') continue if (typeof event.data.sessionId !== 'string' || event.data.sessionId.length === 0 || !Number.isSafeInteger(event.data.throughSeq) || event.data.throughSeq < 0 @@ -58,7 +61,7 @@ export function acceptedThrough(session: Session): number { if (event.data.sessionId !== session.id) continue throughSeq = Math.max(throughSeq, event.data.throughSeq) } - acceptanceFolds.set(session, { scannedEvents: events.length, throughSeq }) + acceptanceFolds.set(session, { scannedEvents: length, throughSeq }) return throughSeq } @@ -77,10 +80,9 @@ export function apply(ctx: Context, config: Config): void { if (session === undefined) return undefined const afterSeq = acceptedThrough(session) - const snapshot = session.events - const throughSeq = snapshot.length - 1 + const throughSeq = session.seq - 1 if (throughSeq < 0) return undefined - const suffix = snapshot.slice(afterSeq + 1) + const suffix = session.snapshotEvents(afterSeq + 1) const value: DeepSeekSessionLogExtension = { version: 1, session: session.header, diff --git a/packages/session/session-log-deepseek/src/invariant.ts b/packages/session/session-log-deepseek/src/invariant.ts index e739126ab6..b7c2c848f9 100644 --- a/packages/session/session-log-deepseek/src/invariant.ts +++ b/packages/session/session-log-deepseek/src/invariant.ts @@ -28,7 +28,7 @@ function validateDeliveryAccepted(session: Session, event: SessionEvent<'session /** Validate acceptance watermarks already present in one Session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'session-log-deepseek/delivery-accepted') validateDeliveryAccepted(session, event, fail) } } diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index d473cc7de9..38526420e0 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -76,7 +76,7 @@ describe('incremental DeepSeek session-log upload', () => { first.session.append('turn/start', { turn: 1 }) const prepared = await first.ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: first.session.id }) await prepared.accept() - const seed = first.session.events + const seed = first.session.snapshotEvents() const resumed = await harness('parent', seed) expect(SessionLogDeepSeek.acceptedThrough(resumed.session)).toBe(0) @@ -110,13 +110,14 @@ describe('incremental DeepSeek session-log upload', () => { { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, data: { sessionId: id, throughSeq: 0 } }, ] let reads = 0 - const observed = new Proxy(events, { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) reads++ - return Reflect.get(target, property, receiver) as unknown + const session = { + id, + get seq() { return events.length }, + eventAt(seq: number) { + reads++ + return events[seq] }, - }) - const session = { id, get events() { return observed } } as unknown as Session + } as unknown as Session expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0) expect(reads).toBe(2) @@ -132,6 +133,17 @@ describe('incremental DeepSeek session-log upload', () => { expect(reads).toBe(2) }) + it('rejects a missing event below the captured Session length', () => { + const session = { + id: SessionId('missing-event'), + seq: 1, + eventAt: () => undefined, + } as unknown as Session + + expect(() => SessionLogDeepSeek.acceptedThrough(session)) + .toThrow('session-log-deepseek: missing event 0 below captured length 1') + }) + it('omits the field for direct or stale requests and uploads the prior acceptance marker next', async () => { const { ctx, session } = await harness('edges') await expect(ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL })) @@ -155,7 +167,7 @@ describe('incremental DeepSeek session-log upload', () => { const { ctx, session } = await harness('direct-events') session.append('turn/start', { turn: 1 }) const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL, sessionId: session.id }) - expect(prepared.fields.dsh_session_log?.events).toEqual(session.events) + expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) it('fails closed on a malformed persisted acceptance watermark', async () => { diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 02901c7313..e6ec7d7c35 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index e244d3af51..e08e2c628c 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -592,13 +592,13 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { const loaded = await ctx.sessionPersistence.load(child.id) // The constructor seed reaches disk verbatim, then the child's end-seed. - expect(loaded.events.slice(0, source.events.length)).toEqual(source.events) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.events.length }) + expect(loaded.events.slice(0, source.snapshotEvents().length)).toEqual(source.snapshotEvents()) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.snapshotEvents().length }) expect(loaded.meta).toMatchObject({ id: SessionId('persist-child'), cwd: '/workspace', parentSession: SessionId('persist-parent'), - seedLength: source.events.length, + seedLength: source.snapshotEvents().length, }) }) @@ -1647,13 +1647,13 @@ describe('JsonlSessionPersistence: edge cases', () => { it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { const session = ctx.sessions.create(SessionId('reject-bad')) // Serializability is enforced at the source: Session.append throws on a BigInt-bearing - // event before it enters session.events, so the durable log can never diverge from the live + // event before it enters session.snapshotEvents(), so the durable log can never diverge from the live // log. The error therefore surfaces synchronously at append, not later during backend flush. expect(() => { session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' }) }).toThrow(/non-JSON-serializable/) // The bad event was rejected, so the log stayed empty. - expect(session.events.length).toBe(0) + expect(session.snapshotEvents().length).toBe(0) }) }) diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 102dda423e..82ce49c43f 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 9d394b23eb..4075b0dc26 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -763,7 +763,7 @@ export class PersistenceCoordinator { this.preparations.release( reservation, reservation.state.owner === undefined - && reservation.source.session.events.length === reservation.source.sessionLength, + && reservation.source.session.seq === reservation.source.sessionLength, ) }, }) @@ -997,7 +997,7 @@ export class PersistenceCoordinator { inspection, session, revision, - sessionLength: session.events.length, + sessionLength: session.seq, tornMarker, closers, } @@ -1054,7 +1054,7 @@ export class PersistenceCoordinator { /** Return one durable immutable view of an already-live Session. */ private async loadLiveSnapshot(session: Session): Promise { - const events = session.events + const events = session.snapshotEvents() await this.flush(session) const state = this.states.get(session.id) /* v8 ignore next -- successful flush always publishes this live session's durable state */ @@ -1068,7 +1068,7 @@ export class PersistenceCoordinator { /** Borrow one immutable view from an already-live Session. */ private inspectLive(session: Session): SessionInspection { - return Object.freeze({ meta: session.header, events: session.events }) + return Object.freeze({ meta: session.header, events: session.snapshotEvents() }) } /** Await one retiring lifecycle with caller cancellation. */ @@ -1252,7 +1252,7 @@ export class PersistenceCoordinator { return restored } // Session owns this stable deep-frozen snapshot; backends only serialize it. - const seed = session.events + const seed = session.snapshotEvents() const live: LiveSessionState = { init: Promise.resolve(), writes: this.createWriteBehind(session, () => live.init), @@ -1274,7 +1274,7 @@ export class PersistenceCoordinator { || session.firstLiveSeq !== state.cursor) { throw new Error(`session "${session.id}" preparation no longer matches its persistence state`) } - const suffix = session.events.slice(state.cursor).map(event => structuredClone(event)) + const suffix = session.snapshotEvents(state.cursor).map(event => structuredClone(event)) this.preparations.attach(reservation) state.owner = session const live: LiveSessionState = { diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index b770df776d..afb7ab59b9 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -307,7 +307,7 @@ describe('PersistenceCoordinator seed ownership', () => { try { const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() }) - const seed = session.events + const seed = session.snapshotEvents() await ctx.sessions.flush(session) expect(backend.lastAppendedBatch).toBe(seed) @@ -767,7 +767,7 @@ describe('PersistenceCoordinator session preparations', () => { first = await coordinator.prepare(id) expect(backend.loadAttempts).toBe(1) - expect(first.session.events[0]).toBe(inspected.events[0]) + expect(first.session.snapshotEvents()[0]).toBe(inspected.events[0]) first[Symbol.dispose]() second = await coordinator.prepare(id) @@ -829,8 +829,8 @@ describe('PersistenceCoordinator session preparations', () => { ) preparation = await coordinator.prepare(id) - expect(preparation.session.events).toHaveLength(9) - expect(preparation.session.events[0]).not.toBe(inspected.events[0]) + expect(preparation.session.snapshotEvents()).toHaveLength(9) + expect(preparation.session.snapshotEvents()[0]).not.toBe(inspected.events[0]) expect(backend.loadAttempts).toBe(2) } finally { preparation?.[Symbol.dispose]() @@ -989,7 +989,7 @@ describe('PersistenceCoordinator session preparations', () => { session.append('turn/start', { turn: 1 }) const inspected = await coordinator.inspect(session.id) - expect(inspected.events).toBe(session.events) + expect(inspected.events).toBe(session.snapshotEvents()) expect(inspected.events.map(event => event.type)).toEqual(['turn/start']) await expect(coordinator.load(session.id)).rejects.toThrow(/live turn is open/) } finally { @@ -1071,7 +1071,7 @@ describe('PersistenceCoordinator session preparations', () => { try { preparation = await coordinator.prepare(id) - expect(preparation.session.events.map(event => event.type)).toEqual([ + expect(preparation.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'turn/end', 'turn/start', @@ -2177,7 +2177,7 @@ describe('SessionPersistence service registration', () => { const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } })) .toThrow(/unsupported legacy request\/header-delta format/) - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) await fiber.dispose() }) @@ -2190,7 +2190,7 @@ describe('SessionPersistence service registration', () => { expect(() => appendLegacy('request/header', legacyFallbackHeader().data)) .toThrow('unsupported legacy request/header reason "fallback"') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) await fiber.dispose() }) diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index f48f0e2868..3c0b6aba03 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index f0071d1497..4963228c45 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 709c5f5f66..488cd72914 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -591,10 +591,10 @@ export class SessionProjectionRegistry extends Service { private cellFor(registration: Registration, session: Session): UnitCell { let cell = registration.cells.get(session) if (cell === undefined) { - cell = this.buildCell(registration.def, session.header, session.events) + cell = this.buildCell(registration.def, session.header, session.snapshotEvents()) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session.events, session.seq - 1) + this.advanceCell(registration.def, cell, session, session.seq - 1) } return cell } @@ -603,12 +603,12 @@ export class SessionProjectionRegistry extends Service { private advanceCell( def: ErasedDefinition, cell: UnitCell, - events: readonly SessionEvent[], + session: Session, throughSeq: number, ): void { if (cell.observedSeq >= throughSeq) return for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { - const event = events[seq] + const event = session.eventAt(seq) if (event === undefined || event.seq !== seq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) } @@ -630,10 +630,10 @@ export class SessionProjectionRegistry extends Service { if (cell === undefined) { // Late build mid-stream: fold history before this event (seq = log // index, so the prefix slice is exact), then take the normal gate. - cell = this.buildCell(registration.def, session.header, session.events.slice(0, event.seq)) + cell = this.buildCell(registration.def, session.header, session.snapshotEvents(0, event.seq)) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session.events, event.seq - 1) + this.advanceCell(registration.def, cell, session, event.seq - 1) } const previousState = cell.state const next = registration.def.apply(previousState, event) diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 2c930a54f8..b00f5ad581 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index f5601caa0f..4c5dc734e0 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-telemetry-otel/README.md -README.md: 8aa215f5fd2e1344911e3f069f038a4f89f6fa3f -README.zh.md: 1bb3aa8f3b3e4b2f50e66e1833a8f4ab00112a8b +README.md: f270878962bdc365915e76a67ffc2490db14d33e +README.zh.md: d8fcef0610b22abddb3f051cb6056d89f3fc65b5 diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 8aa215f5fd..f270878962 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -93,7 +93,7 @@ The backend is a thin adapter over the OTel JS SDK: it owns capture mode, resour ### Capture wiring -`FULL` composes the coordinator in `live` mode and lets direct service calls through; `FEEDBACK_ONLY` composes it in `on-demand` mode, gives the coordinator a private backend capability, and triggers `captureSession(session, event.seq)` only for the exact canonical feedback record; `DISABLED` registers nothing but a warning on `feedback/record`. The backend deliberately implements no `flush()`: the batch processor owns ordinary flushing, and forwarding the hint to `forceFlush()` would create the sole source of concurrent flushes whose interaction with shutdown's drain is undocumented. +`FULL` composes the coordinator in `live` mode and lets direct service calls through; `FEEDBACK_ONLY` composes it in `on-demand` mode, gives the coordinator a private backend capability, and triggers `captureSession(session, event.seq)` only when `session.eventAt(event.seq) === event` confirms the exact canonical feedback record; `DISABLED` registers nothing but a warning on `feedback/record`. The backend deliberately implements no `flush()`: the batch processor owns ordinary flushing, and forwarding the hint to `forceFlush()` would create the sole source of concurrent flushes whose interaction with shutdown's drain is undocumented. ### Field mapping diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 1bb3aa8f3b..d8fcef0610 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -93,7 +93,7 @@ kind: "package-reference" ### 捕获接线 -`FULL` 以 `live` 模式组装协调器,并放行直接服务调用;`FEEDBACK_ONLY` 以 `on-demand` 模式组装协调器,给协调器一个私有后端能力,并且只对权威日志中精确的反馈记录触发 `captureSession(session, event.seq)`;`DISABLED` 除了在 `feedback/record` 上发出警告外不注册任何内容。后端刻意不实现 `flush()`:常规 flush 由批处理器负责,把提示转发给 `forceFlush()` 会成为并发 flush 的唯一来源,而它与关闭排空的交互没有文档。 +`FULL` 以 `live` 模式组装协调器,并放行直接服务调用;`FEEDBACK_ONLY` 以 `on-demand` 模式组装协调器,给协调器一个私有后端能力,并且仅在 `session.eventAt(event.seq) === event` 确认精确的权威反馈记录时触发 `captureSession(session, event.seq)`;`DISABLED` 除了在 `feedback/record` 上发出警告外不注册任何内容。后端刻意不实现 `flush()`:常规 flush 由批处理器负责,把提示转发给 `forceFlush()` 会成为并发 flush 的唯一来源,而它与关闭排空的交互没有文档。 ### 字段映射 diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 8f985cfd0b..05b4e0e9b9 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 169f4dc9de..c88d442c87 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -244,7 +244,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { ctx.on('session/event', (session, event) => { if (event.type !== 'feedback/record') return // Consent is the committed record, not an independently emitted bus value. - if (session.events[event.seq] !== event) { + if (session.eventAt(event.seq) !== event) { ctx.logger.warn(NON_CANONICAL_FEEDBACK_WARNING) return } diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index afb96c8f60..822203abb8 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -150,7 +150,7 @@ describe('OpenTelemetrySessionBackend wire', () => { const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) expect(start).toBeDefined() expect(start?.record.severityNumber).toBe(9) - expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n) + expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.snapshotEvents()[0]!.time) * 1_000_000n) expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } }) const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) @@ -325,7 +325,7 @@ describe('OpenTelemetrySessionBackend wire', () => { }) ctx.emit('session/event', session, { type: 'feedback/record', - seq: session.events.length, + seq: session.snapshotEvents().length, time: Date.now(), data: { text: 'not committed' }, }) diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 2717ec5c76..7f2fe9518a 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index ff0417f168..b558cedf8a 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -139,7 +139,7 @@ export class SessionTelemetryCoordinator { const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { if (event.seq <= cursor) this.track(session, event) diff --git a/packages/session/session-telemetry/tests/redact.spec.ts b/packages/session/session-telemetry/tests/redact.spec.ts index 219c27ebcf..b563d9c469 100644 --- a/packages/session/session-telemetry/tests/redact.spec.ts +++ b/packages/session/session-telemetry/tests/redact.spec.ts @@ -72,7 +72,7 @@ describe('session-telemetry/record waterfall', () => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const logged = session.events[0]!.data as { content: { text: string }[] } + const logged = session.snapshotEvents()[0]!.data as { content: { text: string }[] } expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) }) @@ -124,6 +124,6 @@ describe('session-telemetry/record waterfall', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) expect(backend.records).toHaveLength(0) - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) }) diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index a1d80a1289..e1dc3a13b5 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -96,12 +96,12 @@ describe('SessionTelemetryCoordinator capture', () => { const start = backend.ledger()[0]! const message = backend.ledger()[1]! expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 }) - expect(start.time).toBe(session.events[0]!.time) + expect(start.time).toBe(session.snapshotEvents()[0]!.time) expect(start.severity).toBe('info') expect(message.attributes['event.seq']).toBe(1) // Deep-copy isolation: mutating the handed-off body never reaches the log. ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered' - const logged = session.events[1] as SessionEvent<'user/message'> + const logged = session.snapshotEvents()[1] as SessionEvent<'user/message'> expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) }) @@ -183,7 +183,7 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand') const session = liveSession(ctx, 'on-demand-prefix') appendTurn(session) - const firstBoundary = session.events[1]!.seq + const firstBoundary = session.snapshotEvents()[1]!.seq session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(backend.records).toEqual([]) @@ -288,7 +288,7 @@ describe('SessionTelemetryCoordinator adoption', () => { inject: ['sessions'], apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend), }) - const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + const child = ctx.sessions.prepare(SessionId('seeded'), { seed: parent.snapshotEvents(), meta: {} }) child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) ctx.sessions.enter(child) ctx.sessions.announce(child) @@ -307,7 +307,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) donor.append('turn/start', { turn: 1 }) donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) - const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: donor.snapshotEvents(), meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], @@ -335,7 +335,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const parent = liveSession(ctx, 'stitch-parent') appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { - seed: [...parent.events], + seed: parent.snapshotEvents(), meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, }) await ctx.plugin({ diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 2592db8cd2..da6d18e55f 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts index 8c358bfc35..a16b415b72 100644 --- a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts @@ -53,7 +53,7 @@ describe('all-messages LLM title provider', () => { ctx.llm.registerAdapter(['current-route'], adapter) await ctx.plugin(providerPlugin, LLM_CONFIG) const session = ctx.sessions.create(SessionId('all-plugin'), { - seed: seeded.events, + seed: seeded.snapshotEvents(), meta: { parentSession: seeded.id, seedLength: seeded.seq }, }) session.append('turn/start', { turn: 2 }) diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index 9a1549a730..67acbbd584 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index d9024f735d..019a277528 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index f635c921e8..240ddefa2b 100644 --- a/packages/session/session-title-llm/tests/llm.spec.ts +++ b/packages/session/session-title-llm/tests/llm.spec.ts @@ -126,7 +126,7 @@ describe('generateSessionTitleWithLlm', () => { const providerRequest = request(ctx) let requestWasLoggedAtDispatch = false const adapter = new RecordingAdapter(SCRIPT, () => { - requestWasLoggedAtDispatch = providerRequest.session.events + requestWasLoggedAtDispatch = providerRequest.session.snapshotEvents() .some(event => event.type === 'session/title-llm-request') }) ctx.llm.registerAdapter(['current-route'], adapter) @@ -162,7 +162,7 @@ describe('generateSessionTitleWithLlm', () => { const prompt = options.messages[0]?.content[0] expect(prompt?.type === 'text' && prompt.text).toContain('first prompt') expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题') - expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data) + expect(providerRequest.session.snapshotEvents().findLast(event => event.type === 'session/title-llm-request')?.data) .toEqual({ titleProvider: TITLE_PROVIDER, messageSeqs: providerRequest.messages.map(message => message.seq), @@ -193,7 +193,7 @@ describe('generateSessionTitleWithLlm', () => { await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER)) .rejects.toThrow(/input.*bytes.*maxInputBytes/i) expect(adapter.requests).toEqual([]) - expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false) + expect(oversized.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(false) const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 }) const within = request(ctx) @@ -261,7 +261,7 @@ describe('generateSessionTitleWithLlm', () => { providerRequest.messages, TITLE_PROVIDER, )).rejects.toMatchObject({ message, code }) - expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true) + expect(providerRequest.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(true) }) it.each([ diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index a686ee63a1..ce206ca9dc 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 829fbadbca..4e1c1e378a 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -382,7 +382,7 @@ export class SessionTitleService extends Service { * @returns latest title snapshot, or `undefined` before eligible input. */ get(session: Session): SessionTitleSnapshot | undefined { - return foldSessionTitle(session.events) + return foldSessionTitle(session.snapshotEvents()) } /** @@ -592,7 +592,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) - const messages = collectSessionTitleMessages(session.events, work.throughSeq) + const messages = collectSessionTitleMessages(session.snapshotEvents(), work.throughSeq) const result = await work.registration.provider.generate({ session, messages, diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index e7e707f2dc..ecd2e69ec5 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -66,8 +66,8 @@ describe('SessionTitleService Provider lifecycle', () => { const child = ctx.sessions.fork(parent, undefined, SessionId('title-child')) expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent)) - expect(child.events.find(event => event.type === 'session/title')) - .toEqual(parent.events.find(event => event.type === 'session/title')) + expect(child.snapshotEvents().find(event => event.type === 'session/title')) + .toEqual(parent.snapshotEvents().find(event => event.type === 'session/title')) const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Should not run', @@ -377,7 +377,7 @@ describe('SessionTitleService Provider lifecycle', () => { }))) await settle() - expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1) expect(requests).toHaveLength(2) expect(requests[1]).toMatchObject({ messages: [ diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 7701d953b7..9654c18edc 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -46,14 +46,14 @@ describe('SessionTitleService.rename', () => { messageSeqs: [], source: { kind: 'user' }, }) - const event = session.events.findLast(item => item.type === 'session/title') + const event = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.data).toEqual({ title: 'Hand picked name', messageSeqs: [], source: { kind: 'user' }, }) // foldSessionTitle round-trips the third source kind. - expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' }) + expect(foldSessionTitle(session.snapshotEvents())?.source).toEqual({ kind: 'user' }) }) it('rejects titles that normalize to empty and dead sessions', async () => { @@ -164,7 +164,7 @@ describe('SessionTitleService.rename', () => { await settle() // The released provider result must not append over the user title, and // the swallowed abort must not surface as an unhandled rejection. - const latest = session.events.findLast(item => item.type === 'session/title') + const latest = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } }) }) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index 6ac579bd35..ce3e4997bb 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -171,7 +171,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events }) + const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.snapshotEvents() }) const results = await Promise.all([ ctx.sessionTitle.refresh(session), @@ -179,8 +179,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => { ]) expect(results[0]).toEqual(results[1]) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', @@ -204,7 +204,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' }) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) it('lets the newest overlapping explicit refresh win', async () => { @@ -266,7 +266,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await fiber.dispose() await settle() - expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) const inactiveError = await lifecycle.inactiveRefresh expect(inactiveError).toBeInstanceOf(Error) if (!(inactiveError instanceof Error)) throw new Error('expected inactive refresh to reject') @@ -285,7 +285,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await Promise.resolve() await fiber.dispose() - expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) expect(warn).not.toHaveBeenCalled() }) diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index b96eb6001b..0254ff5826 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -53,7 +53,7 @@ describe('SessionTitleService', () => { await settleTitles() - const titleEvent = session.events.findLast(event => event.type === 'session/title') + const titleEvent = session.snapshotEvents().findLast(event => event.type === 'session/title') expect(titleEvent).toMatchObject({ type: 'session/title', seq: 2, @@ -140,7 +140,7 @@ describe('SessionTitleService', () => { expect(first?.messageSeqs).toEqual([eligible.seq]) expect(ctx.sessionTitle.get(session)).toEqual(first) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) it('folds the latest title event during replay', () => { @@ -160,7 +160,7 @@ describe('SessionTitleService', () => { }, }) - expect(foldSessionTitle(seed.events)).toEqual({ + expect(foldSessionTitle(seed.snapshotEvents())).toEqual({ title: 'Later', messageSeqs: [1, 4], source: { @@ -169,13 +169,13 @@ describe('SessionTitleService', () => { model: { provider: 'mock', model: 'title-model' }, }, eventSeq: 1, - updatedAt: seed.events[1]?.time, + updatedAt: seed.snapshotEvents()[1]?.time, }) }) it('folds an empty or title-less log to undefined', () => { expect(foldSessionTitle([])).toBeUndefined() const empty = Session.create(SessionId('no-title')) - expect(foldSessionTitle(empty.events)).toBeUndefined() + expect(foldSessionTitle(empty.snapshotEvents())).toBeUndefined() }) }) diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index c1be737fc0..61d01e065e 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index a883898387..964c2fca7d 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 24c018ed02..ee38a5b8ba 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index e6fe12741d..c30e0c4bec 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 4d7fe4acea..066eb4423b 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 5e0d84ec7f..a9f20272c4 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 689664f8b0..fe251335f8 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 1345d66411..61e990fea3 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index c4e7edaa17..237daa097e 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index b167998e69..abcfd82ee8 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index a1ec19265d..1eec7f247b 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index c645230ac2..7923382bc8 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -60,13 +60,13 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** Find a session event by type, narrowed; throws when absent. */ function findEvent( - log: SessionEvent[], + log: readonly SessionEvent[], type: T, position: 'first' | 'last' = 'first', ): Extract { diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 623b1bb2f3..fd6bc51b16 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -205,8 +205,8 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start', data: { turn: 1 } }] - if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) + const events: Array<{ type: string; data?: Record; seq: number }> = [{ type: 'turn/start', seq: 0, data: { turn: 1 } }] + if (mode !== undefined) events.push({ type: 'sandbox/mode', seq: events.length, data: { mode } }) const id = SessionId('sandbox-session') return { id, @@ -214,9 +214,11 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, - events, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], + snapshotEvents: () => events, append: (type: string, data: Record) => { - const event = { type, data } + const event = { type, data, seq: events.length } events.push(event) onAppend?.(type) return event @@ -625,9 +627,10 @@ describe('sandbox escalation through the generic task producer', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string }; seq: number }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, + seq: malformed.session.seq, }) expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider') }) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index c25a5f181e..a850d7267c 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 49c3942a30..e881b5af4e 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index e1d15a1bae..c50af1a02f 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -247,9 +247,11 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, - events, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], + snapshotEvents: () => events, append: (type: string, data: Record) => { - const event = { type, data } + const event = { type, data, seq: events.length } events.push(event) onAppend?.(type) return event @@ -270,7 +272,13 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 }, events: [] }, + session: { + id, + header: { version: 0, id, createdAt: 0 }, + seq: 0, + eventAt: () => undefined, + snapshotEvents: () => [], + }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -602,9 +610,10 @@ describe('sandbox escalation through ctx.approval', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string }; seq: number }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, + seq: malformed.session.seq, }) expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider') }) diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 0eaf64b821..3a50b595bd 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index b2b9e2f50b..89cef0556b 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index d549231237..8c781e8ec4 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index fe05359bb5..d036c2cf7a 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 222e8a0ace..c620afd9c5 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -338,7 +338,7 @@ function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { * Entries of one durable catalog message, or undefined when the record is not a * usable catalog. * - * `agent.session.events` may be a resumed, forked, or externally written seed, + * `agent.session.snapshotEvents()` may contain a resumed, forked, or externally written seed, * and seed validation only guarantees a source object with a non-empty `kind`; * no per-kind field is checked there. An unreadable record is therefore treated * as "not this plugin's catalog" — the posture the replaced content digest had — @@ -360,12 +360,12 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const events = agent.session.events let published = false - for (let index = events.length - 1; index >= 0; index -= 1) { - // The loop bounds prove the read-only event view contains this index. - // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! + for (let index = agent.session.seq - 1; index >= 0; index -= 1) { + const event = agent.session.eventAt(index) + if (event === undefined) { + throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`) + } if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue const entries = readCatalogEntries(event.data.source) if (entries === undefined) continue diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 2f7ee6bbc8..7a488ba2e0 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -110,7 +110,7 @@ async function proposeStep( } function catalogMessages(session: Session): Extract[] { - return session.events.filter((event): event is Extract => event.type === 'user/message' + return session.snapshotEvents().filter((event): event is Extract => event.type === 'user/message' && event.data.source.kind === 'skill-catalog') } @@ -544,7 +544,7 @@ describe('dsh-tool-skill', () => { }) it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => { - // Seeds reach `agent.session.events` from persistence on resume or fork, + // Seeds reach `agent.session.snapshotEvents()` from persistence on resume or fork, // and seed validation only guarantees a source object with a non-empty // `kind`. A catalog whose entries are missing or wrongly shaped must be // skipped like any foreign record; throwing here would fail every later @@ -585,6 +585,18 @@ describe('dsh-tool-skill', () => { expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill') }) + it('rejects a missing event below the current Session length', async () => { + const home = await tempDir('tool-catalog-missing-event') + const ctx = await setup(home) + const session = Session.create(SessionId('catalog-missing-event')) + const agent = sessionAgent(session) + openMessageTurn(session) + Object.defineProperty(session, 'eventAt', { value: () => undefined }) + + await expect(fireStep(ctx, agent, 1, 1)) + .rejects.toThrow('skill catalog cannot read seq 1 below the current Session length') + }) + it('re-establishes the current catalog after compaction hides its durable message', async () => { const home = await tempDir('tool-catalog-compaction') const ctx = await setup(home) diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index b02ab12364..2cf803a056 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 98c9a0ae67..afc6cc2c29 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index a1aea3f37c..2f92576e7b 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 1ef2fd8852..2b99e2f67e 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index bc6ed8df05..d9ddc6354c 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index d15a93e136..13265e1f7c 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index cbd5bc2570..6ca591a5ed 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index b85c698c24..9713052168 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index bb8a733a36..0b6264cb41 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index e46db7d9ec..0db7bdffbb 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 7f6d4f672c..650af56015 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index e13bb45018..c6182ecebc 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 9786f585fa..51dd2f44ba 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -46,7 +46,7 @@ export const Config: z = z.object({ * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ function completedTurnPrefix(parent: Agent): SessionEvent[] { - const events = parent.session.events + const events = parent.session.snapshotEvents() const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] // seq === array index (the append contract), so slice up to and including it. diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index f14c5d001b..67b0dd9d70 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -69,7 +69,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { // Parent does one real turn first, so the fork has a completed turn to seed. parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q1' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length // Delegate to a fresh spawn child. const spawnRun = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent }) @@ -90,7 +90,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id) expect(forkChild.session.header.parentSession).toBe(parent.session.header.id) // The fork child inherited the parent's prefix; the spawn child did not. - expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) + expect(forkChild.session.snapshotEvents().slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) await spawnRun.dispose() await forkRun.dispose() @@ -98,12 +98,12 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { // The parent is unaffected and keeps working after both delegations. parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q2' }], source: { kind: 'user' } })) await parent.whenIdle() - const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') + const lastParentMessage = parent.session.snapshotEvents().findLast(e => e.type === 'assistant/message') expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.message.content)).toBe('parent turn two') // The parent's OWN log never recorded the children's internal steps — its // only subagent-related entries would be tool/call+tool/result IF it had // used the tool, but here we called the service directly, so the parent log // is purely its own two turns. - expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(parent.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(2) }) }) diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 1d4a98e60e..ca2618dd7a 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -85,7 +85,7 @@ describe('dsh-subagent-fork-in-process', () => { expect(text(result.output)).toBe('fresh child') const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. - expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(child.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(1) expect(child.session.header.seedLength).toBeUndefined() await run.dispose() }) @@ -96,14 +96,14 @@ describe('dsh-subagent-fork-in-process', () => { await parent.whenIdle() parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) await run.result const child = ctx.agents.get(run.id)! expect(child.session.header.seedLength).toBe(parentPrefixLen) - expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') - expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(child.session.snapshotEvents().slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + expect(child.session.snapshotEvents().slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) @@ -111,7 +111,7 @@ describe('dsh-subagent-fork-in-process', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) const result = await run.result @@ -120,9 +120,9 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! // The child's log STARTS with the parent's prefix (seeded), then its own turn. - expect(child.session.events.length).toBeGreaterThan(parentPrefixLen) + expect(child.session.snapshotEvents().length).toBeGreaterThan(parentPrefixLen) // The seeded prefix carried the parent's user message. - const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message') + const seededUser = child.session.snapshotEvents().slice(0, parentPrefixLen).find(e => e.type === 'user/message') expect(seededUser).toBeDefined() // Lineage stamped. expect(child.session.header.parentSession).toBe(parent.session.header.id) @@ -152,7 +152,7 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! // The child's seed has exactly the ONE completed parent turn (the open one excluded). - const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end') + const seedTurnEnds = child.session.snapshotEvents().filter(e => e.type === 'turn/end') // 1 from the seeded parent turn + 1 from the child's own completed turn. expect(seedTurnEnds.length).toBe(2) diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 31a3aa0013..887111625f 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index 45b7270b52..53fd8f3783 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -68,7 +68,7 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { /** Extra inputs the spawn and fork providers supply to the shared driver. */ export interface InProcessRunOptions { /** Completed-turn seed for fork, or undefined for a fresh spawn. */ - readonly seed?: SessionEvent[] + readonly seed?: readonly SessionEvent[] } /** Error used when cancellation wins before the child publication boundary. */ @@ -212,7 +212,7 @@ function readResult( cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { - const own = child.session.events.slice(boundary) + const own = child.session.snapshotEvents(boundary) // `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its // awaited first turn almost immediately, and the owner's own teardown is the // `cancelled` flag below. A cancellation with no accounting turn resolves diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index 4ff72cba21..e9e1a1dda2 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -71,7 +71,7 @@ function spawnRequest(parent: Agent) { } function toolResultTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') .map(event => event.data.message.content .flatMap(block => block.content) @@ -88,7 +88,7 @@ describe('in-process policy inheritance', () => { setSandboxMode(parent.session, 'read-only') // No parent approval override: the child pin must not depend on one. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() - const parentLogLength = parent.session.events.length + const parentLogLength = parent.session.snapshotEvents().length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), textResponse('child done'), @@ -102,7 +102,7 @@ describe('in-process policy inheritance', () => { await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) expect(result.stopReason).toBe('completed') - expect(child.session.events.slice(0, 2)).toMatchObject([ + expect(child.session.snapshotEvents().slice(0, 2)).toMatchObject([ { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } }, { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, ]) @@ -110,10 +110,10 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') expect(ctx.approval.overrideOf(child.session)).toBe('never') - const request = child.session.events.find( + const request = child.session.snapshotEvents().find( (event): event is SessionEvent<'request/header'> => event.type === 'request/header', ) - const runtimeContext = child.session.events.find( + const runtimeContext = child.session.snapshotEvents().find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt', @@ -130,7 +130,7 @@ describe('in-process policy inheritance', () => { expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') expect(request.data.header.system).not.toContain('You are a delegated subagent') - expect(parent.session.events).toHaveLength(parentLogLength) + expect(parent.session.snapshotEvents()).toHaveLength(parentLogLength) } finally { await run.dispose() } @@ -141,7 +141,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'fork-blocked.txt') setSandboxMode(parent.session, 'workspace-write') - const seed = [...parent.session.events] + const seed = parent.session.snapshotEvents() setSandboxMode(parent.session, 'read-only') script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -156,7 +156,7 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) // seq 1 is the constructor's end-seed marker. - expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + expect(child.session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toMatchObject([ { seq: 0, data: { mode: 'workspace-write' } }, { seq: 2, data: { mode: 'read-only', source: 'delegation' } }, ]) @@ -202,8 +202,8 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') - expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false) - expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + expect(child.session.snapshotEvents().some(event => event.type === 'sandbox/mode')).toBe(false) + expect(child.session.snapshotEvents().filter(event => event.type === 'approval/policy')).toMatchObject([ { seq: 0, data: { policy: 'never', source: 'delegation' } }, ]) expect(child.session.firstLiveSeq).toBe(0) @@ -242,10 +242,10 @@ describe('in-process policy inheritance', () => { expect(consulted).toBe(false) expect(toolResultTexts(child).join('\n')) .toContain('the user rejected escalating this operation to "workspace-write"') - const asked = child.session.events.find( + const asked = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', ) - const decided = child.session.events.find( + const decided = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', ) expect(asked?.data.toolName).toBe('write') diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index e912c2516f..d34729037c 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -87,7 +87,7 @@ describe('a child agent composed in-process', () => { const run = await startInProcessRun(spawnRequest(parent), {}) await run.result - expect(run.localAgent?.session.events.some(event => + expect(run.localAgent?.session.snapshotEvents().some(event => event.type === 'request/header' && JSON.stringify(event.data).includes('section for preset_only'))).toBe(true) await run.dispose() diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index b9cabe8c4d..c145204ea2 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -191,7 +191,7 @@ describe('in-process structured output', () => { expect(result.structured).toEqual({ answer: 5 }) expect(sideEffectRan).toBe(false) const child = ctx.agents.get(run.id) - const sideEffectResult = child?.session.events.find(event => + const sideEffectResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c2') expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.message.content[0].isError).toBe(true) await run.dispose() @@ -235,7 +235,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('completed') // The child's log carries the isError tool/result for the invalid call. const child = ctx.agents.get(run.id)! - const results = child.session.events.filter(e => e.type === 'tool/result') + const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results.length).toBe(2) expect(results[0]!.data.message.content[0].isError).toBe(true) await run.dispose() @@ -253,7 +253,7 @@ describe('in-process structured output', () => { // Exactly one model request and one caller-supplied user message: no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! - expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) + expect(child.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) @@ -318,7 +318,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') // ...the logged tool result is the blocked isError with the feedback... const child = ctx.agents.get(run.id)! - const results = child.session.events.filter(e => e.type === 'tool/result') + const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results[0]!.data.message.content[0].isError).toBe(true) expect(JSON.stringify(results[0]!.data.message.content)).toContain('capture rejected by hook') // ...and the turn CONTINUED past the blocked call (no captured veto): @@ -363,7 +363,7 @@ describe('in-process structured output', () => { expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') const child = ctx.agents.get(run.id) - const captureResult = child?.session.events.find(event => + const captureResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c1') expect(captureResult?.type === 'tool/result' && captureResult.data.message.content[0].isError).toBe(true) await run.dispose() @@ -434,7 +434,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') expect(adapter.requests).toHaveLength(2) const child = ctx.agents.get(run.id)! - const outer = child.session.events.find(event => + const outer = child.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === ToolCallId('c1')) expect(outer?.type === 'tool/result' && outer.data.message.content[0].isError).toBe(true) await run.dispose() diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index b69a549983..cadddf8814 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -152,7 +152,7 @@ describe('startInProcessRun', () => { let injected = false ctx.on('session/flush', (session) => { if (injected || session.header.parentSession === undefined) return - const lastEnd = session.events.findLast(event => event.type === 'turn/end') + const lastEnd = session.snapshotEvents().findLast(event => event.type === 'turn/end') if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return injected = true session.append('user/message', createUserMessage({ @@ -166,7 +166,7 @@ describe('startInProcessRun', () => { const child = ctx.agents.get(run.id)! expect(injected).toBe(false) - expect(child.session.events.findLast(event => event.type === 'turn/end')) + expect(child.session.snapshotEvents().findLast(event => event.type === 'turn/end')) .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) expect(result.stopReason).toBe('max-tokens') await run.dispose() @@ -201,13 +201,13 @@ describe('startInProcessRun', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const seed = parent.session.events.slice() + const seed = parent.session.snapshotEvents() const run = await startInProcessRun(request(parent), { seed }) const result = await run.result expect(text(result.output)).toBe('child answer') const child = ctx.agents.get(run.id)! expect(child.session.header.seedLength).toBe(seed.length) - expect(child.session.events.slice(0, seed.length)).toEqual(seed) + expect(child.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) await run.dispose() }) @@ -328,7 +328,7 @@ describe('startInProcessRun', () => { }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) - const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = child?.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } }) await signalled.dispose() diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 409fad1e26..a2a1b1d3a4 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index ba76c69203..8a17743341 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( // The parent's log records the subagent tool/call + its result (not the // child's internal steps). - const events = [...parent.session.events] + const events = parent.session.snapshotEvents() const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') expect(subagentCalls.length).toBeGreaterThan(0) }, 180_000) diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index b3efa062e7..c46f99514f 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -111,14 +111,14 @@ describe('dsh-subagent-spawn-in-process', () => { const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent prompt' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentEventCount = parent.session.events.length + const parentEventCount = parent.session.snapshotEvents().length expect(parentEventCount).toBeGreaterThan(0) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) await run.result const child = ctx.agents.get(run.id)! // The child's first user/message is its OWN prompt, not the parent's history. - const firstUser = child.session.events.find(e => e.type === 'user/message') + const firstUser = child.session.snapshotEvents().find(e => e.type === 'user/message') expect(firstUser).toBeDefined() await run.dispose() }) @@ -434,7 +434,7 @@ describe('dsh-subagent-spawn-in-process', () => { expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool') // …and the attempted call executed as UNKNOWN_TOOL (visible in the log). const child = ctx.agents.get(run.id)! - const toolResult = child.session.events.find(e => e.type === 'tool/result')! + const toolResult = child.session.snapshotEvents().find(e => e.type === 'tool/result')! expect(JSON.stringify(toolResult.data)).toContain('unknown tool') await run.dispose() }) diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 92637766f0..e33435f317 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts index a6b5dcf3e1..4190395023 100644 --- a/packages/subagent/subagent/src/descriptor-seed.ts +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -24,8 +24,8 @@ export function seedDescriptorTurn( childId: SessionId, seed: readonly SessionEvent[] | undefined, descriptor: SubagentDescriptorData, -): SessionEvent[] { +): readonly SessionEvent[] { const staged = Session.create(childId, seed) staged.append('subagent/descriptor', descriptor) - return [...staged.events] + return staged.snapshotEvents() } diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 2e1d513d5a..2f4e6d5392 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -193,11 +193,11 @@ export function createActivationObserver( : { stopReason: 'error' } return { start: (child: Agent): void => { - boundary = child.session.events.length + boundary = child.session.seq emit('subagent/start', identity, parent) }, capture: (child: Agent): void => { - const own = child.session.events.slice(boundary) + const own = child.session.snapshotEvents(boundary) const output = finalAssistantOutput(own) captured = { stopReason: epochStopReason(own), diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 64d6bd21c3..6906f6b58b 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -195,7 +195,7 @@ describe('SubagentRuntime.startContinuable', () => { ctx.on('agent/inbox/inserted', ({ agent, message }) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. - enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.snapshotEvents(), 'child task') }) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -405,7 +405,7 @@ describe('SubagentRuntime.startContinuable', () => { expect(found).toBeDefined() return found! }) - const descriptor = child.session.events.find(event => event.type === 'subagent/descriptor') + const descriptor = child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor') expect(descriptor?.data).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, @@ -440,7 +440,7 @@ describe('SubagentRuntime.startContinuable', () => { return found! }) - expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data) + expect(child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor')?.data) .toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', @@ -1482,7 +1482,7 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - const before = child.session.events.length + const before = child.session.snapshotEvents().length const controller = new AbortController() controller.abort('caller gave up') @@ -1815,7 +1815,7 @@ describe('continuable review regressions', () => { /** Every settlement notice this agent received, in order, as flat text. */ function settlementNotices(agent: Agent): { sender: string; text: string; summary: string }[] { - const logged = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + const logged = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...logged, ...agent.inbox.nextStep, ...agent.inbox.nextTurn].flatMap((message) => { if (message.source.kind !== 'subagent-settled') return [] return [{ @@ -1850,7 +1850,7 @@ describe('continuable report delivery', () => { await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) }) - const report = parent.session.events.flatMap(event => event.type === 'user/message' + const report = parent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'subagent-report' ? [event.data] : [])[0] expect(report?.id).toBe(messageId) @@ -2087,7 +2087,7 @@ describe('continuable settlement delivery', () => { // Turn 1 closed cleanly and no later turn opened, so the cancelled queue is // the only record that this epoch was cut short. - expect(hasUserText(child.session.events, 'never runs')).toBe(false) + expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( `Background subagent ${started.childId} was stopped before it finished.` @@ -2264,8 +2264,8 @@ describe('continuable settlement delivery', () => { `Background subagent ${started.childId} was stopped before it finished.` + '\nIt left no closing message.', ) - expect(parent.session.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true) - expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) + expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(parent.status).toBe('idle') }) @@ -2281,7 +2281,7 @@ describe('continuable settlement delivery', () => { await drained expect(settlementNotices(parent)).toHaveLength(1) - expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) it('records but cannot deliver a teardown notice once the parent is disposed too', async () => { diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index f8b0987b12..a7ad3ecdb7 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index d8c2a02fc2..f5c742242c 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 30a5568d94..91ca89bd6d 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -140,7 +140,7 @@ function registerReportConflict(child: Agent): () => void { /** Reports already visible or still pending in one Agent. */ function reports(agent: Agent): { id: string; text: string; sender: string }[] { - const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + const visible = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...visible, ...agent.inbox.nextStep].flatMap((message) => { if (message.source.kind !== 'subagent-report') return [] return [{ diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 660bf0ba4d..bfe7d2aa8b 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md -README.md: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3 -README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b +README.md: 8dabfe42e2aa200294820dfb0c3bc366134d6ac9 +README.zh.md: b3efaae8355d998cb0f47735fac98a368c16277b diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5520e98ddc..8dabfe42e2 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -64,7 +64,7 @@ Under `continuable` policy, an omitted or `true` `run_in_background` starts a du ### Selecting a child LLM -Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each top-level Session is composed. When enabled, its non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. +Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each fresh top-level Session is composed. A restored Session without a recorded policy remains disabled, including an explicitly empty restore. When enabled, the non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 069eca4f8c..b3efaae835 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 选择子级 LLM -设置 `modelSelectionSettings: true`,即可在组合每个顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 +设置 `modelSelectionSettings: true`,即可在组合每个全新顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。没有已记录策略的恢复 Session 会保持禁用,包括显式为空的恢复。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 8e0cafbfb8..8160177479 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 18a327d352..3450e5d1bc 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -617,6 +617,8 @@ export function apply(ctx: Context, config: Config): void { } const selectForAgent = (agent: NonNullable): ModelSelectionPolicy | undefined => { + const freshSession = agent.session.firstLiveSeq === 0 + && agent.session.eventAt(0)?.type !== 'session/end-seed' let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, agent.session) if (allowedModels === undefined) { const parentId = agent.session.header.origin === 'subagent' @@ -627,7 +629,7 @@ export function apply(ctx: Context, config: Config): void { allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(ctx.sessionProjections, parent.session) - } else if (agent.session.firstLiveSeq === 0) { + } else if (freshSession) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 5282ee0a63..6926b64888 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -306,7 +306,7 @@ describe('SubagentModelSelectionConfig', () => { const enabledSeed = Session.create(SessionId('enabled-seed')) enabledSeed.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) - const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events }) + const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.snapshotEvents() }) expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) @@ -314,7 +314,11 @@ describe('SubagentModelSelectionConfig', () => { enabled: true, allowedModels: ALLOWED_MODELS, }) - const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) + const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] }) + expect(selectable(ctx, resumedEmpty)).toBe(false) + expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() + + const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 8d968883b8..838e941f32 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index c584c162bf..ef09857b90 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 2f60a83d27..100c84a312 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 74a7ed1a2b..b6dfd6732a 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 148f9e37bd..4799709b3f 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -615,7 +615,7 @@ describe('terminal-bash plugin shape', () => { expect(() => { setSandboxMode(session, 'read-only') }).toThrow( 'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first', ) - expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(1) const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) const second = await ctx.terminals.spawn(owner, { type: 'stub' }) @@ -625,7 +625,7 @@ describe('terminal-bash plugin shape', () => { await ctx.terminals.kill(owner, created.sessionId) await ctx.terminals.kill(owner, second.sessionId) expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow() - expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2) + expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(2) }) it('also fences sandbox-mode changes across unpublished PTY creation', async () => { diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index f6e03e76b5..1613707fcb 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index cd1bf4d366..aa009ff57e 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index d67580bf51..bf50e9c698 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 22a04c704d..57722159ca 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index ccce7a4e2f..b85b14747b 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index a95547fd2e..90a45ea3bc 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 57e6195858..6fe0957930 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index b8ce37b908..b441032ea7 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts index 916b61aa0d..6830cbaf4b 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts @@ -19,7 +19,7 @@ export function apply(ctx: Context): void { || exec.arguments.file_path !== 'nested/task.txt') return downstream const agent = exec.agent const baseline = agent.session.surface.nodes - .map(seq => agent.session.events[seq]) + .map(seq => agent.session.snapshotEvents()[seq]) .find(event => event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 2cd2516a6f..1b2f215c2f 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index fee7088a40..21970cbcb9 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -60,7 +60,7 @@ function validateEvent(event: SessionEvent, trace: TurnTrace, fail: InvariantFai /** Validate one existing log in a single pass and return its tail trace. */ function seedTrace(session: Session, fail: InvariantFailure): TurnTrace { const trace: TurnTrace = { open: false } - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateEvent(event, trace, fail) advanceTrace(trace, event) } diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 0692772ea9..2ca91bb10f 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -65,7 +65,7 @@ describe('todo_write tool through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan a two-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() expect(findEvent(log, 'tool/call').data.name).toBe('todo_write') expect(findEvent(log, 'tool/result').data.message.content[0].isError).toBe(false) @@ -93,9 +93,9 @@ describe('todo_write tool through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan then update' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const todoEvents = agent.session.events.filter(e => e.type === 'todo/write') + const todoEvents = agent.session.snapshotEvents().filter(e => e.type === 'todo/write') expect(todoEvents).toHaveLength(2) - expect(findEvent(agent.session.events, 'todo/write', 'last').data.todos).toEqual([ + expect(findEvent(agent.session.snapshotEvents(), 'todo/write', 'last').data.todos).toEqual([ { content: 'step one', status: 'completed' }, { content: 'step two', status: 'in_progress' }, ]) diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index 7d53ad052b..c8f52e7c80 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -66,10 +66,10 @@ describe('todo snapshot invariants', () => { const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const before = [...session.events] + const before = session.snapshotEvents() expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/) - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) }) it('rejects an existing snapshot outside an open turn on late registration', async () => { diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 565269f1e8..d25d02bba9 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -111,7 +111,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { }) expect(result.isError).toBe(true) expect(resultText(result)).toContain('at most one task may be in_progress') - expect(owner.session.events.some(e => e.type === 'todo/write')).toBe(false) + expect(owner.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }, 30_000) it('allowParallelInProgress: true permits a parallel write end to end', async () => { @@ -128,7 +128,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { agent: owner, }) expect(result.isError).toBe(false) - expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) + expect(owner.session.snapshotEvents().findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) }, 30_000) it.each([ diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 713ef08e5c..c54f710c33 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -82,7 +82,7 @@ describe('dsh-tool-todo', () => { }) expect(text(result)).toContain('1 pending, 1 in progress, 0 completed') - const event = agent.session.events.findLast(e => e.type === 'todo/write')! + const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual(todos) }) @@ -92,7 +92,7 @@ describe('dsh-tool-todo', () => { const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent }) expect(result.isError).toBe(false) - const event = agent.session.events.findLast(e => e.type === 'todo/write')! + const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual([{ content: 'plan the work', status: 'pending' }]) }) @@ -105,7 +105,7 @@ describe('dsh-tool-todo', () => { { content: 'b', status: 'in_progress' }, ] }, { agent }) - const current = agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos + const current = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'a', status: 'completed' }, { content: 'b', status: 'in_progress' }, @@ -139,7 +139,7 @@ describe('dsh-tool-todo', () => { todos, counts: { pending: 1, inProgress: 2, completed: 0 }, }) - expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) + expect(agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) describe('allowParallelInProgress', () => { @@ -155,7 +155,7 @@ describe('dsh-tool-todo', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('at most one task may be in_progress') // A rejected call must not reach the durable log. - expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }) it('false still accepts one active item', async () => { @@ -247,7 +247,7 @@ describe('todo/write event', () => { ] session.append('todo/write', { todos }) - const event = session.events.findLast(e => e.type === 'todo/write')! + const event = session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.type).toBe('todo/write') expect(event.data.todos).toEqual(todos) @@ -268,7 +268,7 @@ describe('todo/write event', () => { { content: 'second', status: 'in_progress' }, ] }) - const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos + const current = session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'first', status: 'completed' }, { content: 'second', status: 'in_progress' }, @@ -293,11 +293,11 @@ describe('todo/write event', () => { original.append('turn/start', { turn: 1 }) original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('t4-replay'), [...original.events]) + const replayed = Session.create(SessionId('t4-replay'), original.snapshotEvents()) - expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos) + expect(replayed.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.firstLiveSeq).toBe(original.seq) }) }) diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index fd3efd1361..c7bafbbb26 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index be1d0a6d68..8a96f3e98f 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 41a879e8c4..ea60c199a8 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 94786e6394..2eab9ad65f 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 577d31f0b3..7dea605024 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 3f8792cba5..12e2cb39fe 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded-string primitives for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index b93eb0307a..1a85a1499c 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index b5a8bd39d1..cd199daf05 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index a5c857ef7c..95d7885bcf 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 06ab4ba55f..d91ebc6ba2 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 9d0516cbe3..ce49914ac3 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index b8f8a0343e..8f25770d57 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 1c422b8a78..5973808833 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 940eb01c14..6a85edbfd6 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index f18a8bd0ea..e63754bc86 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index 81a4841617..0ae6549178 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index c4a99ae8cb..c815de56ce 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index ffe992baa6..2c8db1586e 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 471bcf912e..2eaf5d16ed 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index a064c8f1bf..37dfacd279 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 32775c3f6c..e4acdff92f 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 78d3c8fda4..b0542aaf76 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 7d81eebcaf..ef72ae6112 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 98c5795000..65175c91ab 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 08024a74c1..43a7bc7378 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 93c3866b48..688e97bacb 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 4b32a03f66..d36174b94f 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -135,7 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) + for (const event of session.snapshotEvents().filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) traces.set(session, trace) return trace } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 06e8b0511b..e3404d6c12 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -146,7 +146,7 @@ describe('dsh-tool-workflow', () => { engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 }) expect((await pending).isError).toBe(false) expect(engine.disposed).toBe(1) - expect(session.events.map(event => [event.type, event.data])).toEqual([ + expect(session.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }], ['tool-workflow/agent-start', { runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1', @@ -166,10 +166,10 @@ describe('dsh-tool-workflow', () => { value: null, stopReason: 'completed', agentsStarted: 0, }) await vi.waitFor(() => { expect(engine.disposed).toBe(1) }) - expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start']) + expect(session.snapshotEvents().map(event => event.type)).toEqual(['tool-workflow/run-start']) barrier.resolve(undefined) expect((await pending).isError).toBe(false) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'tool-workflow/run-start', 'tool-workflow/run-end', ]) }) @@ -190,9 +190,9 @@ describe('dsh-tool-workflow', () => { engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 }) expect((await first).isError).toBe(false) expect((await second).isError).toBe(true) - expect(session.events.filter(event => event.type === 'tool-workflow/agent-start')) + expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/agent-start')) .toHaveLength(1) - expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) + expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) .toEqual([ { runId: 'run-1', stopReason: 'completed' }, { runId: 'run-2', stopReason: 'error' }, @@ -208,7 +208,7 @@ describe('dsh-tool-workflow', () => { await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 }) expect((await pending).isError).toBe(false) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it.each([ @@ -240,7 +240,7 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain(failedType) - const types = session.events.map(event => event.type) + const types = session.snapshotEvents().map(event => event.type) const expectedPrefixes = { 'tool-workflow/run-start': [], 'tool-workflow/agent-start': ['tool-workflow/run-start'], diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 3440975584..59845fb526 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 2a41cb7b0e..d7785293e5 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 8d14357419..43dba0a32a 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" },